UX Overhaul — Phase A Design Spec (Hero Flow)
UX Overhaul — Phase A Design Spec (Hero Flow)
Section titled “UX Overhaul — Phase A Design Spec (Hero Flow)”Status: Brainstorm complete (all 7 screens locked as of 2026-04-19) — ready for implementation planning Scope: Welcome tour → Auth → Home → Triage → Results → Pro Profile → Booking form Direction: Italian Sole (terracotta/olive/cream) Fonts: Gambarino (display) + Switzer (body) Related docs:
.impeccable.md— design context, user types, canonical constraints, hard bansplans/ideony-ux-overhaul-plan.md— master plan, phasing, open decisionsplans/specs/2026-04-19-preview-system-design.md— preview infra spec (merged PR #1)
Canonical Constraints (not negotiable)
Section titled “Canonical Constraints (not negotiable)”Fold into every screen:
- Home is ChatGPT/Claude-style prompt, NOT category grid
- Conversational triage (AI w/ tool-use) between prompt and results
- Multi-quote comparison in results (ProntoPro-stolen)
- Verificato badge first-class component, three states (Verified/Pending/Unverified)
- Two pricing display modes: hourly “€45/h” vs fixed “€60 fisso”
- Pre-booking contact masked; chat only post-booking
- SOS red (
#E5484D) reserved exclusively for active SOS dispatch state - Italian price certainty — never show dynamic surge
Auth Pattern
Section titled “Auth Pattern”Identifier-first unified flow (SOTA — Stripe, Airbnb, Linear, Notion, Vercel, Uber, Revolut, Shopify, Wise, Apple). Single route /(welcome)/continue. User enters email/phone → Clerk auto-detects existing vs new → branches internally. Sign-up diverges at step 2 (adds name + phone + role chip “Cerco un pro / Sono un pro”).
Map Provider
Section titled “Map Provider”Mapbox Day 1 (replaces prior Google Maps plan). @rnmapbox/maps + Mapbox APIs (Geocoding, Directions, Matrix, Places). Custom Sole-palette map style via Mapbox Studio. 50K MAU + 100K API/mo free tier. Reference: Airbnb, Lyft, Strava.
AI / Agentic
Section titled “AI / Agentic”LangGraph.js + GPT-5.4 Mini primary + Gemini 2.5 Flash fallback (verified in apps/api/src/modules/ai/agent.factory.ts). LangSmith EU for tracing.
Screen 1 — Welcome Tour ✅ Locked
Section titled “Screen 1 — Welcome Tour ✅ Locked”Skeleton: A — Full-bleed photo overlay (cinematic, continues into Auth)
Structure (all 3 slides):
- Top: logo “Ideony” + “Salta” skip
- Hero area: full-bleed imagery (photo or visual element)
- Bottom gradient overlay: Gambarino serif headline (2 lines max) + Switzer body copy + pagination dots + CTA button
Slides:
- Trust — “Descrivi il problema.” Hero: photo of craftsman workspace + chat bubble overlay (“Perdita sotto il lavello, urgente…”). Verified pro card tease at bottom-middle.
- Speed — “Pronti quando serve.” Hero: real Mapbox map w/ live user location + 3 nearby pro markers (amber pulse rings). Bottom overlay: ETA strip “3 disponibili · ~12 min” with mono countdown font.
- Calm — “Prezzi chiari, zero sorprese.” Hero: 3-card price comparison fan, middle card highlighted (MIGLIOR badge, €38 fisso). Fixed-price terminology visible.
Motion (react-native-reanimated):
- Cross-fade + 8% zoom on background transition between slides
- Chat bubble (slide 1) slides out → map pin pulses in (slide 2) → cards shuffle in (slide 3)
- Headline staggered reveal (y:20→0, opacity 0→1, 60ms delay per line)
- Button: scale 0.95→1, 200ms spring
- Dot pagination: liquid morph
- ~1.2s total entry per slide, 60fps target
Map slide implementation:
- Real
@rnmapbox/mapsview, custom Sole style - Prefetch user location on app launch (NOT during tour render — saves 300ms)
- Fallback: Milan center if permission denied/slow
- Real API call to
/professionals/nearby?lat=&lng=(public endpoint, no auth needed) - Render 3 amber-pulse markers; “you” = cream pin w/ amber ring (NOT red — red reserved for active SOS)
Imagery:
- Phase A (MVP): Unsplash curated pack — 4 photos per slide, randomized per session
- Phase B (post-seed): real pro portraits sourced via onboarding photo shoot
Open items (Screen 1):
- Photo curation — who picks the 12 photos (4 per slide)? Design handoff artifact.
- Video variant of tour (optional future): 5s looping B-roll behind static text.
Screen 2 — Auth ✅ Locked
Section titled “Screen 2 — Auth ✅ Locked”Skeleton: A — Full-bleed continuation. Dark warm gradient (same as tour slide 1), zero visual break between tour and auth.
Pattern: Clerk identifier-first. Single route /(welcome)/continue.
Structure:
- Top: back arrow (circle w/ transparent bg) + “Accedi” label right
- Hero area: “Benvenuto.” (Gambarino serif) + “Accedi per continuare con Ideony.” subtitle
- Social buttons stack: “Continua con Apple” (cream bg, dark text) + “Continua con Google” (glassy translucent)
- Divider: “o”
- Email input: translucent bg, light border, amber focus ring (Sole)
- Primary CTA: amber accent “Continua”
- Footer: “Nuovo su Ideony? Registrati” (text link, amber underline — routes to Clerk sign-up internally)
Clerk theming:
- Use
<SignIn appearance={...} />prop with Sole tokens appearance.variables:colorPrimary: #B35F3B,colorBackground: transparent,fontFamily: Switzer, etc.- Fallback to custom-built form using Clerk hooks only if Appearance prop insufficient
Error states:
- Invalid email: border turns
#E5484D, error text below input in same red - Clerk API errors: map to Italian via i18n (see
feedback_clerk_e2e.mdreference)
Screen 3 — Home (prompt-first) ✅ Locked
Section titled “Screen 3 — Home (prompt-first) ✅ Locked”Layout: A+ — Pure prompt + map affordance strip + full-screen map modal on tap.
Top bar:
- “Ideony” logo (Gambarino) + ”📍 Milano” location pill
- User avatar circle top-right (gradient, Sole palette)
Hero block (center, takes ~50% vertical):
- Greeting: “Cosa ti serve, {firstName}?” (Gambarino serif, 28px, centered)
- Subhead: “Descrivi il problema e trovi il pro giusto.” (Switzer, muted)
Prompt card:
- Cream bg, 1.5px light border, 18px radius
- Placeholder: “Esempio: Perdita sotto il lavello, posso inviare una foto…”
- Bottom row: attach icons (📎 file, 📷 photo, 🎤 voice) + send button (circle ↑)
- Expands to full-screen input on focus (multimodal keyboard)
Map affordance strip (below prompt):
- Small pill: left = mini-map thumbnail (12×12 Mapbox snapshot w/ pro pin dots), right = “12 pro nella tua zona / Esplora sulla mappa →”
- Tap → full-screen Mapbox modal
Full-screen map modal:
- Dark map style (custom Mapbox Sole-dark)
- Category filter chips top (Tutti / Idraulica / Elettricità / Fabbro / …)
- Pro markers: avatar circles w/ cream border (gradient bg matches pro’s category color)
- User location: cream pin w/ amber ring
- Bottom sheet on pin tap: pro preview card w/ “Prenota” CTA
- Top-right toggle: “Lista” → flips to Airbnb-card list (same data)
- Bottom-right ◎ re-centers location
Category chips (below map strip, optional shortcuts):
- ”💧 Idraulica”, ”⚡ Elettricità”, ”🔑 Fabbro” — tapping pre-fills prompt
Bottom nav:
- 5 tabs: 🏠 Cerca · 📅 Prenota. · SOS (FAB centered) · 💬 Chat · 👤 Profilo
- SOS FAB: 52×52 circle, red gradient (
linear-gradient(135deg, #E5484D, #B8383C)), floats -24px above nav, heavy shadow - Active tab: terracotta
#B35F3B, inactive:#7C6A4D
Screen 4 — Triage ✅ Locked
Section titled “Screen 4 — Triage ✅ Locked”Pattern: Conversational AI w/ tool-use (Claude-experience). AI decides per-turn: ask or search.
LLM orchestration (LangGraph):
- Tools:
askUserQuestion(question: string, options: string[])— pauses flow, renders question w/ chip optionssearchProfessionals(filters: {category, urgency, homeType?, budgetTier?, locationText})— triggers results
- System prompt: “Search immediately IF you have category + location + urgency. Otherwise ask ONE missing piece. Never ask more than 4 questions. Italian language.”
- Streaming: SSE from
/triage/streamendpoint, user sees AI “typing” dots - Hard cap: 4 questions or 60s wall → force-search with extracted filters + defaults
Chat UI:
- Top bar: back arrow + “Ricerca” title + “Assistente attivo” green dot + “Salta” top-right
- Message thread:
- User bubble: terracotta bg, cream text, bottom-right tail (14px radius w/ 4px bottom-right)
- AI bubble: cream-amber bg (#F5E6CB), dark text, bottom-left tail; avatar circle 28px (gradient) to left
- AI “typing”: 3 amber dots pulsing
- Chip-reply options: vertical stack below last AI message, 40px left indent
- Always-visible “Salta” top-right → force search with extracted filters
State behaviors:
- Ambiguous prompt (“Perdita sotto il lavello”): AI asks category, then urgency, then … (1-4 chips each)
- Clear prompt (full context): AI extracts filters → shows chip strip confirming extraction → transitions to Results via “Vedi N risultati” CTA
- Freeform text reply: user can type free text back (LLM re-triages); fallback input at bottom of chat
Extraction target schema:
{ category: 'plumbing' | 'electrical' | 'locksmith' | ... urgency: 'sos_1h' | 'sos_2h' | 'same_day' | 'this_week' | 'flexible' homeType?: 'apartment' | 'house' | 'office' | ... budgetTier?: 'low' | 'mid' | 'high' | null hasAttempted?: boolean locationText: string // free-form, geocoded via Mapbox}Screen 5 — Results ✅ Locked
Section titled “Screen 5 — Results ✅ Locked”Layout: B+ — Compact list + warm details + Confronta mode toggle.
Top bar:
- Back arrow + “3 idraulici disponibili” (Gambarino) + ”📍 Milano · ⏱️ entro 1h · 🚨 emergenza” meta
- Top-right: ”⊕ Confronta” pill button (cream-amber bg, terracotta border)
Filter chips row (below header, horizontal scroll):
- Primary: “Tutti · 3” (dark filled)
- Filters: ”✓ Verificato”, ”★ 4.5+”, ”≤ €50/h”, ”📍 Mappa”
Card structure:
- 18px radius, warm shadow
0 10px 30px rgba(139,74,43,0.12) - Left tile (90px × full): gradient bg per pro category (terracotta/olive/amber), first-letter-initial in Gambarino 26px, verified badge top-left corner (green pill w/ ✓)
- Right area (padding 12px 14px):
- Top row: name (700 weight) + distance; right-aligned price
- Hourly: Georgia serif “€45” + Switzer “/h”, sub-line ”+ surcharge”
- Fixed: terracotta Georgia “€60” + caption “FISSO” (terracotta 700 weight)
- Bottom row (6px gap, flex-wrap pills):
- Rating: ”★ 4.9 · 127” (amber tint pill)
- ETA (SOS/emergency only): ”🚨 ETA 20 min” (red tint pill)
- Fast responder: ”💬 Risponde veloce” (olive tint pill)
- Top row: name (700 weight) + distance; right-aligned price
Empty state: big Gambarino “Ancora nessuno in zona” + helper text + “Attiva SOS” CTA. Loading state: 3 skeleton cards w/ warm-cream shimmer.
Confronta mode (entered via top-right button):
- Header turns dark ink bg w/ cream text: “Modalità confronta · Seleziona fino a 3 pro”
- Each card gains 22px checkbox left of avatar tile
- Selected cards: terracotta 2px border + checkmark filled
- Badge counter top-right: “2/3” (amber pill)
- Bottom: sticky compact preview showing up-to-3 mini-cards side-by-side w/ price + rating
- CTA: “Confronta 2 →” (terracotta, full-width)
- Tap-through → dedicated compare screen (spec TBD in Phase C; for Phase A, shows modal w/ 3 columns)
Screen 6 — Professional Profile ✅ Locked (skeleton A — Airbnb-stay)
Section titled “Screen 6 — Professional Profile ✅ Locked (skeleton A — Airbnb-stay)”Pattern: Airbnb listing-style. Photo carousel hero → identity strip w/ overlapping avatar → stats bar (rating / experience / response time) → scrollable sections (Descrizione, Portfolio, Prossima disponibilità) → sticky bottom price + CTA bar.
Header (220px hero):
- Full-bleed photo carousel of portfolio works (not avatar) — craft proof via real work photos
- Top-left circular back button (
rgba(253,248,239,0.95)w/ backdrop-blur) - Top-right: heart (save) + share icons (same style)
- Bottom-right:
N / totalcounter pill (rgba(43,30,16,0.7)) - Swipeable horizontally; tap → full-screen gallery (swipeable)
Identity strip:
- 54px circular avatar, 3px cream border,
margin-top:-30pxoverlap over hero, warm drop-shadow - Name (Gambarino 20px) +
✓ VERIFICATOolive pill inline - Subline (Switzer 12px,
#7C6A4D): “Idraulico · Milano · dal 2014”
Stats bar (#F5E6CB background, 12px radius):
- Three stats w/ vertical dividers: rating + review count · years of experience · avg response time (
< 10m) - Values in Gambarino 15px weight 700; labels Switzer 10px
#7C6A4D
Scrollable body sections (serif 14px section titles):
- Descrizione — bio paragraph (Switzer 13px, 1.45 line-height)
- Portfolio · N lavori — horizontal scroll of 80×80px tiles, tap → full-screen gallery
- Prossima disponibilità — horizontal scroll of next-available slot pills (selected = dark, rest = outlined)
- Recensioni (added to spec): top 3 reviews preview + “Vedi tutte (127) →” link
- Certificazioni verificate (added): chip row of VERIFIED credentials (F-GAS, P.IVA, Assicurazione, etc.) in
#F5E6CB; tappable → modal w/ issuer + dates + watermarked preview. Read endpointGET /professionals/:id/credentialsreturns[]until Phase D upload UX ships. Seeplans/specs/2026-04-19-pro-credentials-design.mdfor full trust score + ranking + upload spec. - Zona di lavoro (added): small Mapbox static map tile showing service radius
Sticky bottom bar:
- Left: price block — Gambarino 18px
€45/h(or€60 fissofor fixed) + Switzer 10px “IVA e commissioni incluse” (or ”+ surcharge emergenza” for hourly-with-SOS) - Right: chat icon button (💬, outline terracotta, 44px square — NO full chat button per blueprint anti-disintermediation rule) + “Prenota” primary CTA (terracotta, 14px bold)
- Box-shadow
0 -8px 20px rgba(139,74,43,0.08)— warm lift
Chat CTA rule (blueprint-locked):
- Icon-only button. Chat unlocks AFTER booking confirmed (contact-masking pre-booking). Pre-booking chat = disabled state w/ tooltip “Chat disponibile dopo prenotazione” on tap.
Motion:
- Hero carousel: native horizontal swipe, pagination dot indicator
- Sticky bar: fades in on scroll > 40px, y-translate from 100% → 0 w/ 200ms ease-out
- Avatar overlap: parallax (hero scrolls slower than content, 0.3× ratio) — subtle
- Tap slot pill → hero shrinks, booking form pushes from bottom (Phase A Screen 7)
Data:
type ProfessionalProfile = { id: string; displayName: string; verified: boolean; category: string; // "Idraulico" city: string; yearsOfExperience: number; rating: number; // 4.9 reviewCount: number; avgResponseTimeMinutes: number; // < 10m bio: string; portfolio: { id: string; url: string; caption?: string }[]; certifications: string[]; // ["F-GAS", "P.IVA"] pricing: | { mode: 'hourly'; ratePerHourEur: number; sosSurchargeEur?: number } | { mode: 'fixed'; flatFeeEur: number }; nextAvailableSlots: { startAt: string; endAt: string }[]; serviceRadiusKm: number; serviceCenter: { lat: number; lng: number }; topReviews: { id: string; author: string; rating: number; text: string; createdAt: string }[];};Shared rules applied:
- Fixed-price pros: replace
€45/h→€60 fisso, remove surcharge line, add “Prezzo garantito” olive pill next to price - Chat button icon-only, disabled pre-booking
- Verificato badge always next to name
Screen 7 — Booking Form ✅ Locked (skeleton C — Bottom-sheet incremental)
Section titled “Screen 7 — Booking Form ✅ Locked (skeleton C — Bottom-sheet incremental)”Pattern: Pro profile stays visible behind (dimmed/blurred), bottom sheet slides up + grows as user fills each step. Summary chips above current step show completed data. Auto-advance on valid input (no explicit “next” button until final confirm).
Entry points:
- Screen 6 “Prenota” CTA → sheet opens pre-filled w/ slot from profile tap
- Screen 5 Results card tap (single-pick flow) → sheet opens w/ slot=null, slot picker is step 1
- SOS flow → SOS variant (see below)
Step flow (normal booking):
- Quando (if not pre-filled) — calendar + slot picker within sheet
- Dove — address autocomplete (Mapbox Places) → auto-advance on valid pick → Mapbox static map preview w/ draggable pin (for micro-correction) → piano/interno/note optional inline fields
- Descrizione — textarea (prefilled if came from triage) + photo picker (up to 5, compressed client-side, R2 upload via pre-signed)
- Conferma — price breakdown + payment method + “Conferma” CTA
Summary chip pattern:
- Completed steps become tappable green pills above current step:
✓ Mar 19 · 15:00,✓ Via Dante 12 - Pending steps grey:
📸 Foto? - Tap chip → jumps back to that step (sheet content swaps w/ 200ms slide, chip returns to inline)
Price breakdown (explicit, always visible in step 4):
Tariffa (1h stimata) €45,00Commissione Ideony €5,00IVA €11,00──────────────────────────Totale €61,00✓ Addebito solo a servizio completato- Blueprint rule: commission transparent by default (anti-ProntoPro)
- Hourly pros: estimate based on triage-extracted urgency + category avg duration; disclaimer “Stima — addebito finale in base al tempo effettivo”
- Fixed-price pros: flat line “€60 fisso · tutto incluso”
Payment:
- Stripe PaymentIntent,
capture_method: 'manual'(auth on confirm, capture on completion) - Default: user’s saved card from previous booking (Stripe Customer)
- First-time: opens
@stripe/stripe-react-nativePaymentSheet for add-card - Payment methods surface: VISA/MC brand icon + last 4 + “Cambia”
Motion:
- Sheet entrance: spring from bottom (
useSharedValue+withSpringw/ damping 20, stiffness 100) - Pro profile behind:
blur(8px)+ opacity 0.4 - Step transition: content fades out 150ms → chip pops into summary (spring scale 0.8→1) → new step fades in 150ms
- Confirm button: haptic medium + button morph (→ spinner → checkmark) → whole sheet slides down → route push to
/booking/[id]confirmation screen
SOS variant (Screen 7 fork):
- Triggered from SOS red button (Home Screen 3 or results w/ SOS filter)
- Skip slot picker (snap to
now) - Skip “Descrizione” as separate step — triage already captured
- Skip payment method selection — use default card, no optional adds
- Confirm CTA replaced w/ “Invia SOS a N idraulici” (broadcast fan-out to top N candidates, not single pro)
- On confirm →
POST /sosreturnsdispatchId→ sheet transforms into live dispatch state (see Dispatch Flow below)
Data:
type BookingRequest = { professionalId: string; // null for SOS broadcast startAt: string; // ISO durationMinutesEstimate: number; address: { formatted: string; lat: number; lng: number; floor?: string; unit?: string; notes?: string; }; description: string; photoUrls: string[]; // R2 keys paymentMethodId: string; // Stripe pm_xxx mode: 'normal' | 'sos';};Ranking — Two Paths (consolidated)
Section titled “Ranking — Two Paths (consolidated)”Search/Results ranking (Screen 5, weighted for trust):
searchScore = rating×10 + trustScore + proximityBonus − responseLatencyPenaltySOS dispatch ranking (urgency-weighted, different priorities):
sosScore = proximityScore×50 + onlineBonus×30 + trustScore×0.5 − avgResponseMinutesSOS prioritizes proximity + availability (minutes matter); search prioritizes quality. Both consume trustScore but at different weights. Defined in:
- Search:
apps/api/src/modules/professionals/ranking.ts(new) - SOS:
apps/api/src/modules/sos/candidate-ranker.ts(new)
POST /sos Endpoint Contract
Section titled “POST /sos Endpoint Contract”POST /sosIdempotency-Key: <uuid-v4> # Required — client-generated, Redis-dedup 10minAuthorization: Bearer <clerk-jwt>Rate-Limit: 3 per 5min per user # 429 beyond
Request:{ "address": { formatted, lat, lng, floor?, unit?, notes? }, "description": string, // From triage "photoUrls": string[], // R2 keys, max 5 "categoryId": string, "paymentMethodId": string, // Stripe pm_xxx "estimatedDurationMinutes": number}
Response (202 Accepted):{ "dispatchId": "dsp_xxx", "status": "BROADCASTING", "candidatesNotified": number, "expiresAt": ISO-timestamp, "broadcastRoundsScheduled": 3}
Errors:409 - no_candidates_available (post retry round 3)429 - rate_limited402 - payment_method_invalidWebSocket Auth (dispatch rooms)
Section titled “WebSocket Auth (dispatch rooms)”Socket.IO dispatch:${dispatchId} room membership:
- Handshake: client sends Clerk JWT in
auth.token - Server validates via
@clerk/backend verifyToken()on connection event - Before joining room: check DB
dispatch.acceptedBy === userId OR dispatch.consumerId === userId. Reject withws_unauthorizedotherwise. - Token refresh: Clerk JWTs expire 60s — client re-auths on expiry via
authenticateevent w/ fresh JWT; server extends session or disconnects. - On
completed/cancelledstate: server force-disconnects both parties.
SOS Dispatch Flow 🆕 Locked (ride-hail pattern)
Section titled “SOS Dispatch Flow 🆕 Locked (ride-hail pattern)”Pattern: Uber/Bolt driver-app model. Consumer broadcasts SOS → top N eligible pros receive push notification w/ request details → first to accept wins → real-time chat + live tracking + directions unlock for both sides.
Dispatch logic (BE):
POST /sosreceivesBookingRequestw/mode: 'sos'- Rank eligible pros: online + SOS-enabled + serviceRadius covers address + category match + not currently on another job
- Sort by: proximity DESC, trustScore DESC, avgResponseTime ASC
- Send push to top 5 simultaneously via Novu (channel: Expo Push)
- 60s timeout: if no accept → expand to next 5; repeat up to 3 rounds (total 15 candidates)
- First pro to tap “Accetta” → BE atomic lock under
REPEATABLE READtransaction:Empty result → 409 ConflictUPDATE dispatchSET acceptedBy = :proId,acceptedAt = NOW(),status = 'ACCEPTED'WHERE id = :dispatchIdAND acceptedBy IS NULLAND status = 'BROADCASTING'AND expiresAt > NOW()RETURNING *;{ error: "already_taken" }. Idempotent: pro tapping twice returns their own row. - Winner gets confirmation; losers get “Troppo tardi, richiesta presa da un altro idraulico”
- All other candidates’ pushes auto-dismissed via Expo notification category
Pro push notification payload:
{ "title": "🚨 SOS · Idraulico richiesto", "body": "Via Dante 12, Milano · 0.8 km · €45/h + surcharge SOS", "data": { "type": "sos_dispatch", "dispatchId": "dsp_xxx", "expiresAt": "2026-04-19T14:31:00Z", "summary": "Perdita sotto il lavandino, gocciola da stamattina", "photoThumbUrl": "https://r2.../thumb.jpg" }, "categoryIdentifier": "SOS_DISPATCH", "sound": "sos-alert.caf", "priority": "max", "android": { "channelId": "sos_dispatch" }}Pro app SOS-dispatch screen (deep-link from notification tap):
- Full-screen takeover (cannot be dismissed — must accept or reject)
- Hero: countdown ring (60s, terracotta) around address summary
- Body: consumer’s triage description + photos carousel + distance + price preview
- Actions: large red “Rifiuta” + large terracotta “Accetta · €45” (60% width)
- Haptic heavy + custom sound loop until action/timeout
- Tap outside → cannot dismiss (modal trap)
On pro accept:
- Atomic lock succeeds → both sides route to
/dispatch/[dispatchId]live screen - Consumer sees: pro avatar + name + ETA estimate + “Arriva fra X min” + map w/ pro’s live location marker + route polyline (Tier 2 — drawn once at accept, redrawn only on >200m deviation)
- Pro sees: consumer address + photos + chat button + “Avviato” / “Arrivato” / “Completato” state buttons + external navigation chooser (“Apri in:” → Apple Maps / Google Maps / Waze picker sheet)
- Chat unlocks for both (Socket.IO room
dispatch:${dispatchId}) - Live tracking: pro phone emits GPS every 10s via Socket.IO
location.update→ consumer map updates smoothly (interpolate between pings for 60fps feel)
Cost-conscious routing strategy (Tier 2 — locked):
- No in-app turn-by-turn navigation. Pro taps “Apri in Maps” → OS action sheet w/ Apple Maps / Google Maps / Waze choices. Zero Mapbox Directions API cost for navigation.
- Consumer map = Mapbox tiles + route polyline drawn ONCE at pro-accept time. Pro marker animates along map as GPS pings arrive.
- Route redraw trigger: pro deviates > 200m from polyline → one additional Directions call to refresh route. Typical dispatch: 1-3 calls total.
- ETA computation: initial ETA from the Directions call (road-accurate); subsequent ETAs recomputed locally (remaining polyline distance ÷ avg speed) between redraws.
Cost math: ~3 Mapbox Directions calls per dispatch. 100 dispatches/day = ~9K calls/mo. Mapbox free tier = 100K Directions calls/mo → break-even at ~33K dispatches/mo. Beyond free tier: $0.0005/call.
Deep-link URL patterns (pro nav):
- Apple Maps:
maps://?daddr=LAT,LNG&dirflg=d - Google Maps:
https://www.google.com/maps/dir/?api=1&destination=LAT,LNG&travelmode=driving - Waze:
waze://?ll=LAT,LNG&navigate=yes - Fallback if none installed: open Google Maps in browser (https)
Pro state machine:
accepted → en_route → arrived → working → completed ↘ cancelled (refund rules apply)Each transition: push to consumer + chat system message (“Marco è arrivato”).
Live tracking data:
type DispatchLocation = { dispatchId: string; proLat: number; proLng: number; bearing: number; // Heading for marker rotation speedKmh: number; etaMinutes: number; // Initial from Mapbox Directions (at accept); subsequent values computed locally from remaining polyline distance updatedAt: string;};Privacy:
- Consumer sees pro location ONLY during active dispatch (accepted → completed)
- Pro sees consumer address ONLY after accept (pre-accept push shows street + distance, no exact pinpoint)
- Both sides’ phone numbers masked via Twilio Proxy until dispatch active (even then routed through proxy so post-job numbers auto-expire)
Scope note (Phase mapping):
- Screen 7 booking form SOS variant = Phase A (consumer side: confirm + watch broadcast)
- Full SOS dispatch screens (consumer live tracking + pro accept/live/navigate) = Phase B (SOS sprint)
- Separate spec to be written:
plans/specs/2026-04-19-sos-dispatch-design.md(placeholder — draft in Phase B sprint)
Known Risks (from architect review 2026-04-19)
Section titled “Known Risks (from architect review 2026-04-19)”Watch during implementation — not spec gaps, but implementation pitfalls:
- Mapbox cost math: 33K-dispatch break-even in free tier ignores Geocoding (every address) + Places autocomplete (keystroke burn) + Matrix (ranking distance). Realistic burn ~10× dispatch-only. Action: Sentry alert at 70% of 100K monthly quota, per-endpoint counter in Redis.
- Socket.IO fan-out on CAX11: above ~50 concurrent active dispatches the single-node chokes. Action: Redis adapter + sticky sessions config in impl; scale-out plan documented in Phase 8 infra hardening.
- R2 upload cleanup: abandoned photos from cancelled bookings orphan R2 keys. Action: BullMQ cleanup job nightly — delete R2 keys not linked to any
booking.photoUrlsafter 24h. - Preview persona query-param leak:
?persona=consumer|proon CF Pages is world-readable. Action: hard-gate behindEXPO_PUBLIC_MSW=trueenv assertion at boot; crash if MSW on in production bundle. - trustScore recompute race: 5min debounce on review signal vs immediate recompute on credential status change. Action: advisory lock (
pg_advisory_xact_lock(hash('trust:' || proId))) in recompute worker; last-write-wins semantics documented.
Screen 8 — Booking Confirmation (stretch — Phase A+)
Section titled “Screen 8 — Booking Confirmation (stretch — Phase A+)”Brief mention — not brainstormed. Post-confirm success screen: “Prenotazione inviata” + animated checkmark + pro avatar + next steps card + “Vai alla prenotazione →” CTA. Unlocks chat. Details in Phase C.
Change Log
Section titled “Change Log”- 2026-04-19 — Spec created. Locked: Tour (skeleton A, 3 slides, Mapbox map slide 2), Auth (identifier-first unified, skeleton A continuation), Home (A+ prompt + map affordance), Triage (conversational AI w/ LangGraph tool-use), Results (B+ compact list warmed). Pending: Pro Profile, Booking Form.
- 2026-04-19 — Provider/infra swaps: Mapbox Day 1 (replacing Google Maps plan); GPT-5.4 Mini primary + Gemini 2.5 Flash fallback (reflecting current code state).
- 2026-04-19 — Screen 6 locked (Airbnb-stay skeleton A).
- 2026-04-19 — Spun off pro credentials + trust ranking system into separate spec (
plans/specs/2026-04-19-pro-credentials-design.md) — read surface in Phase A, upload UX + admin queue in Phase D. - 2026-04-19 — Screen 7 locked (skeleton C bottom-sheet incremental). SOS dispatch flow locked (ride-hail pattern: push → accept → live tracking). Pro navigation = OS deep-link chooser (Apple Maps/Google Maps/Waze), NOT in-app turn-by-turn. Consumer live tracking = Tier 2 (Mapbox tiles + route polyline drawn once at accept, redrawn on >200m deviation, ~3 Directions API calls/dispatch; break-even ~33K dispatches/mo within 100K/mo free tier).
- 2026-04-19 — Architect review fixes: fixed SOS atomic lock SQL (added
id = :dispatchIdguard + REPEATABLE READ + status transition), consolidated search vs SOS ranking into separate formulas, addedPOST /sosendpoint contract w/ idempotency key + rate limit, added WebSocket auth spec for dispatch rooms, added Known Risks section (Mapbox cost math, Socket.IO fan-out on CAX11, R2 orphan cleanup, preview persona leak, trustScore recompute race).