Skip to content

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 bans
  • plans/ideony-ux-overhaul-plan.md — master plan, phasing, open decisions
  • plans/specs/2026-04-19-preview-system-design.md — preview infra spec (merged PR #1)

Fold into every screen:

  1. Home is ChatGPT/Claude-style prompt, NOT category grid
  2. Conversational triage (AI w/ tool-use) between prompt and results
  3. Multi-quote comparison in results (ProntoPro-stolen)
  4. Verificato badge first-class component, three states (Verified/Pending/Unverified)
  5. Two pricing display modes: hourly “€45/h” vs fixed “€60 fisso”
  6. Pre-booking contact masked; chat only post-booking
  7. SOS red (#E5484D) reserved exclusively for active SOS dispatch state
  8. Italian price certainty — never show dynamic surge

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”).

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.

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.

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:

  1. Trust — “Descrivi il problema.” Hero: photo of craftsman workspace + chat bubble overlay (“Perdita sotto il lavello, urgente…”). Verified pro card tease at bottom-middle.
  2. 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.
  3. 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/maps view, 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.

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.md reference)

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

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 options
    • searchProfessionals(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/stream endpoint, 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
}

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)

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 / total counter pill (rgba(43,30,16,0.7))
  • Swipeable horizontally; tap → full-screen gallery (swipeable)

Identity strip:

  • 54px circular avatar, 3px cream border, margin-top:-30px overlap over hero, warm drop-shadow
  • Name (Gambarino 20px) + ✓ VERIFICATO olive 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 endpoint GET /professionals/:id/credentials returns [] until Phase D upload UX ships. See plans/specs/2026-04-19-pro-credentials-design.md for 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 fisso for 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):

  1. Quando (if not pre-filled) — calendar + slot picker within sheet
  2. 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
  3. Descrizione — textarea (prefilled if came from triage) + photo picker (up to 5, compressed client-side, R2 upload via pre-signed)
  4. 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,00
Commissione Ideony €5,00
IVA €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-native PaymentSheet for add-card
  • Payment methods surface: VISA/MC brand icon + last 4 + “Cambia”

Motion:

  • Sheet entrance: spring from bottom (useSharedValue + withSpring w/ 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 /sos returns dispatchId → 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';
};

Search/Results ranking (Screen 5, weighted for trust):

searchScore = rating×10 + trustScore + proximityBonus − responseLatencyPenalty

SOS dispatch ranking (urgency-weighted, different priorities):

sosScore = proximityScore×50 + onlineBonus×30 + trustScore×0.5 − avgResponseMinutes

SOS 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
Idempotency-Key: <uuid-v4> # Required — client-generated, Redis-dedup 10min
Authorization: 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_limited
402 - payment_method_invalid

Socket.IO dispatch:${dispatchId} room membership:

  1. Handshake: client sends Clerk JWT in auth.token
  2. Server validates via @clerk/backend verifyToken() on connection event
  3. Before joining room: check DB dispatch.acceptedBy === userId OR dispatch.consumerId === userId. Reject with ws_unauthorized otherwise.
  4. Token refresh: Clerk JWTs expire 60s — client re-auths on expiry via authenticate event w/ fresh JWT; server extends session or disconnects.
  5. On completed/cancelled state: 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):

  1. POST /sos receives BookingRequest w/ mode: 'sos'
  2. Rank eligible pros: online + SOS-enabled + serviceRadius covers address + category match + not currently on another job
  3. Sort by: proximity DESC, trustScore DESC, avgResponseTime ASC
  4. Send push to top 5 simultaneously via Novu (channel: Expo Push)
  5. 60s timeout: if no accept → expand to next 5; repeat up to 3 rounds (total 15 candidates)
  6. First pro to tap “Accetta” → BE atomic lock under REPEATABLE READ transaction:
    UPDATE dispatch
    SET acceptedBy = :proId,
    acceptedAt = NOW(),
    status = 'ACCEPTED'
    WHERE id = :dispatchId
    AND acceptedBy IS NULL
    AND status = 'BROADCASTING'
    AND expiresAt > NOW()
    RETURNING *;
    Empty result → 409 Conflict { error: "already_taken" }. Idempotent: pro tapping twice returns their own row.
  7. Winner gets confirmation; losers get “Troppo tardi, richiesta presa da un altro idraulico”
  8. 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:

  1. Atomic lock succeeds → both sides route to /dispatch/[dispatchId] live screen
  2. 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)
  3. Pro sees: consumer address + photos + chat button + “Avviato” / “Arrivato” / “Completato” state buttons + external navigation chooser (“Apri in:” → Apple Maps / Google Maps / Waze picker sheet)
  4. Chat unlocks for both (Socket.IO room dispatch:${dispatchId})
  5. 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.photoUrls after 24h.
  • Preview persona query-param leak: ?persona=consumer|pro on CF Pages is world-readable. Action: hard-gate behind EXPO_PUBLIC_MSW=true env 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.

  • 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 = :dispatchId guard + REPEATABLE READ + status transition), consolidated search vs SOS ranking into separate formulas, added POST /sos endpoint 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).