Overview
The trip booking system allows passengers to:
- Select pickup location (GPS or manual)
- Search and select destination
- Choose vehicle category and service class
- View route and estimated fare
- Request a trip
The flow is managed by TripPlannerFacade, which orchestrates location services, Mapbox integration, and API calls.
Trip Planning Workflow
1. Initialize Trip Planner
src/app/features/tabs/map/map.component.ts
import { TripPlannerFacade } from '@/app/store/trips/trip-planner.facade';
export class MapComponent implements AfterViewInit {
private facade = inject(TripPlannerFacade);
async ngAfterViewInit() {
await this.facade.init();
// Facade automatically:
// - Hydrates origin from cache or GPS
// - Sets up destination autocomplete
// - Loads vehicle categories and service classes
// - Starts location tracking
}
}
2. Destination Search
The facade provides autocomplete suggestions using Mapbox Places API:
src/app/features/tabs/home/home.component.ts
export class HomeComponent {
private facade = inject(TripPlannerFacade);
// Bind to input
onDestinationInput(text: string) {
this.facade.onDestinationInput(text);
// Facade debounces and searches Mapbox
}
// Access suggestions
suggestions = computed(() => this.facade.store.suggestions());
// User selects a suggestion
selectPlace(item: PlaceSuggestion) {
this.facade.pickSuggestion(item);
// Navigates to /map with destination set
}
}
PlaceSuggestion Interface:
interface PlaceSuggestion {
id: string;
text: string; // Short name (e.g., "Parque Céspedes")
placeName: string; // Full address
coords: {
lat: number;
lng: number;
};
}
3. Route Calculation
Once origin and destination are set, the facade automatically calculates the route:
// Automatic route calculation (internal)
private autoRouteEffect = effect(() => {
const ready = this.store.readyToRoute();
const hasRoute = !!this.store.routeSummary();
if (ready && !hasRoute && !this.store.loading()) {
this.computeRouteAndStore().pipe(take(1)).subscribe();
}
});
RouteSummary Interface:
interface RouteSummary {
origin: LatLng;
destination: LatLng;
originLabel?: string;
destinationLabel?: string;
distanceKm: number; // e.g., 12.3
durationMin: number; // e.g., 24
geometry: FeatureCollection<LineString>; // GeoJSON for map
}
The route geometry is automatically drawn on the map using Mapbox GL JS. See Map Integration for details.
4. Manual Destination Adjustment
Users can drag the destination marker to adjust the endpoint:
src/app/features/tabs/map/map.component.ts
// In MapComponent
this.map.on('click', (e) => {
const { lng, lat } = e.lngLat;
this.facade.recalcRouteAfterAdjust({ lat, lng });
});
// Draggable marker
this.destMarker.on('dragend', () => {
const ll = this.destMarker.getLngLat();
this.facade.recalcRouteAfterAdjust({ lat: ll.lat, lng: ll.lng });
});
Vehicle Selection
Loading Categories
// Facade automatically loads categories on init
this.facade.ensureCatalogLoaded();
// Access categories
vehicleTypesVm = computed(() => {
const cats = this.facade.store.vehicleCategories();
const sel = this.facade.selectedVehicleId();
return cats.map(c => ({
value: c.id,
label: c.label, // e.g., "Sedán", "SUV"
selected: c.id === sel
}));
});
Service Classes
serviceTypesVm = computed(() => {
const list = this.facade.store.serviceClasses();
const sel = this.facade.selectedServiceClassId();
return list.map(s => ({
value: s.id,
label: s.label, // e.g., "Económico", "Premium"
selected: s.id === sel
}));
});
Selecting Options
// Select vehicle category
this.facade.selectVehicle(categoryId);
// Select service class
this.facade.selectServiceType(classId);
Fare Estimation
The facade automatically estimates the fare when route and selections are ready:
// Automatic estimation (internal)
private estimateFx = effect(() => {
const rsReady = !!this.store.routeSummary();
const vid = this.store.selectedVehicleId();
const sid = this.store.selectedServiceClassId();
if (!rsReady || !vid || !sid) return;
const req = this.buildEstimateRequest();
this.tripsApi.estimateTrip(req).subscribe(quote => {
this.store.setFareQuote(quote);
});
});
EstimateTripRequest:
interface EstimateTripRequest {
vehicleCategoryId: string;
serviceClassId: string;
pickup: { lat: number; lng: number };
stops: Array<{ lat: number; lng: number }>;
currency: 'CUP' | 'USD';
}
FareQuote Response:
interface FareQuote {
totalEstimated: number; // e.g., 150.00
currency: string; // "CUP"
breakdown?: {
base: number;
distance: number;
time: number;
surge?: number;
};
}
Displaying Fare
tripDetailsVm = computed(() => {
const rs = this.facade.store.routeSummary();
const fq = this.facade.store.fareQuote();
return {
hasRoute: !!rs,
distanceText: rs ? `${rs.distanceKm.toFixed(1)} km` : '—',
durationText: rs ? `${rs.durationMin} min` : '—',
totalEstimated: fq?.totalEstimated ?? null,
currency: fq?.currency ?? null
};
});
Trip Request
Building the Payload
const payload = this.facade.buildCreateTripPayload({
passengerId: userId,
paymentMode: 'cash', // 'cash' | 'card' | 'wallet'
vehicleCategoryId: selectedVehicleId,
serviceClassId: selectedServiceClassId,
pickupAddress: 'Calle Enramadas 123', // Optional
idempotencyKey: crypto.randomUUID() // Optional
});
CreateTripRequest Structure:
interface CreateTripRequest {
passengerId: string;
paymentMode: 'cash' | 'card' | 'wallet';
pickupPoint: { lat: number; lng: number };
pickupAddress?: string;
stops: Array<{
point: { lat: number; lng: number };
address?: string;
}>;
vehicleCategoryId: string;
serviceClassId: string;
idempotencyKey?: string;
}
Submitting the Request
// High-level method
this.facade.requestTrip({
payment: 'cash',
pickupAddress: 'Calle Enramadas 123'
});
// Facade delegates to TripPassengerFacade
// which:
// - Creates the trip via API
// - Starts driver search with 25s timeout
// - Shows "Searching for driver" UI
// - Handles "no drivers found" scenario
Always use an idempotency key to prevent duplicate trip creation if the request is retried due to network issues.
Trip Planning Store
The TripPlannerStore maintains the planning state:
interface TripPlannerState {
// Location
originPoint: LatLng | null;
originText: string | null;
destinationPoint: LatLng | null;
destinationText: string | null;
// Route
routeSummary: RouteSummary | null;
// Catalog
vehicleCategories: VehicleCategory[];
serviceClasses: ServiceClass[];
selectedVehicleId: string | null;
selectedServiceClassId: string | null;
// Estimation
fareQuote: FareQuote | null;
// UI
suggestions: PlaceSuggestion[];
loading: boolean;
error: string | null;
}
Key Selectors
// Check if ready to calculate route
readyToRoute = computed(() => {
const o = this.store.originPoint();
const d = this.store.destinationPoint();
return !!(o && d);
});
// Get current origin
getOrigin(): LatLng | null {
return this.facade.getOrigin();
}
// Get current destination
getDestination(): LatLng | null {
return this.facade.getDestination();
}
Cache and Persistence
The facade caches origin and destination in localStorage with 24-hour TTL:
// Automatic persistence (internal)
private persistFx = effect(() => {
const o = this.store.originPoint();
const ol = this.store.originText();
if (o) {
localStorage.setItem('trip.origin', JSON.stringify({
lat: o.lat,
lng: o.lng,
label: ol,
ts: Date.now()
}));
}
});
// Automatic hydration on init
private hydrateFromCache() {
const raw = localStorage.getItem('trip.origin');
if (raw) {
const v = JSON.parse(raw);
const fresh = Date.now() - v.ts < 24 * 3600_000;
if (fresh) {
this.store.setOriginPoint({ lat: v.lat, lng: v.lng });
this.store.setOriginText(v.label);
}
}
}
Origin is cached to avoid re-fetching GPS location on every app launch. Destination is not cached by default to encourage fresh searches.
Location Tracking
The facade tracks user location in real-time to keep origin updated:
// Continuous location tracking (internal)
this.originFollowSub = this.loc.watchBalanced()
.pipe(sampleTime(4000)) // Every 4 seconds
.subscribe(sample => {
const next = { lat: sample.lat, lng: sample.lng };
const shouldUpdate = !last || distance(last, next) > 40; // 40m threshold
if (shouldUpdate) {
this.store.setOriginPoint(next, { invalidate: true });
last = next;
}
});
Location updates only trigger route recalculation if the user has moved more than 40 meters. This prevents excessive API calls.
Complete Example
import { TripPlannerFacade } from '@/app/store/trips/trip-planner.facade';
export class HomeComponent implements OnInit {
private facade = inject(TripPlannerFacade);
destination = '';
suggestions = computed(() => this.facade.store.suggestions());
async ngOnInit() {
await this.facade.init();
}
onDestinationInput(text: string) {
this.destination = text;
this.facade.onDestinationInput(text);
}
selectSuggestion(item: PlaceSuggestion) {
this.facade.pickSuggestion(item);
// Navigates to /map
}
clearDestination() {
this.destination = '';
this.facade.clearDestination();
}
}