"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { getVoiceRequests } from "@/lib/experience/voice"; import type { VoiceRequest } from "@/types/experience"; // The story the hero plays out, end to end: // open Nearle → speak → AI understands → AI finds a nearby merchant → // order created → merchant accepts → packed → rider → delivered. // // Note there is no store-selection step. The merchant is *discovered by the AI* // after it understands the request — that is the whole point of voice commerce. export type VoiceStage = | "home" // iOS home screen, Nearle icon pulsing | "tap" // touch indicator presses the icon | "launch" // icon expands into the app | "listening" // mic open, waveform idling | "speaking" // transcript building word by word | "thinking" // AI reasoning steps | "merchant" // nearby merchant resolved | "order" // draft order + confirm | "confirmed" // order placed | "fulfilment" // merchant → packing → rider | "delivered"; // handed over export const AI_STEPS = [ "Understanding request…", "Searching nearby merchants…", "Checking availability…", "Comparing options…", "Creating order…", ] as const; export const FULFILMENT_STEPS = [ { label: "Order confirmed", icon: "receipt_long" }, { label: "Merchant accepts", icon: "storefront" }, { label: "Packing your order", icon: "inventory_2" }, { label: "Nearle rider assigned", icon: "two_wheeler" }, { label: "Delivered", icon: "check_circle" }, ] as const; // A booked service is fulfilled by a person turning up, not a rider dropping a // bag — so it gets its own track, same five beats and the same pacing. export const SERVICE_FULFILMENT_STEPS = [ { label: "Booking confirmed", icon: "receipt_long" }, { label: "Professional accepts", icon: "engineering" }, { label: "Slot locked in", icon: "event_available" }, { label: "On the way to you", icon: "two_wheeler" }, { label: "Job completed", icon: "check_circle" }, ] as const; // Pacing. Tuned so a visitor reads each beat without waiting on it — the whole // story lands in one ~24s pass, and the first 8s already says "speak, AI // understands, a nearby shop fulfils it". const TIMING = { home: 1500, // let the home screen register, icon pulses tap: 450, // press down on the Nearle icon launch: 700, // iOS-style expand into the app listening: 1200, // mic opens, waveform idles wordMs: 190, // per-word cadence of the transcript afterSpeak: 620, // beat between the last word and the AI starting thinkStepMs: 900, // gap between reasoning steps thinkFinalHold: 700, // dwell on the last step merchant: 1900, // read the merchant card order: 2600, // read the items + total, then confirm auto-taps confirmed: 1100, // success bloom fulfilStepMs: 1050, // each fulfilment beat stays readable delivered: 1900, // hold on delivered before the next request } as const; const REQUESTS = getVoiceRequests(); export type VoiceJourney = { stage: VoiceStage; request: VoiceRequest; requests: VoiceRequest[]; /** Words of the transcript revealed so far. */ spokenWords: number; thinkingStep: number; fulfilStep: number; reduced: boolean; start: () => void; stop: () => void; }; // One shared state machine drives the phone screen. Timers only run while the // caller says it is visible (start/stop from an in-view observer), so the loop // costs nothing when the hero is scrolled past. export function useVoiceJourney(): VoiceJourney { const reduce = useReducedMotion() ?? false; const [running, setRunning] = useState(false); const [stage, setStage] = useState("home"); const [index, setIndex] = useState(0); // Tagged with the request it belongs to, so a new request starts from zero // words without needing to reset anything. const [spoken, setSpoken] = useState({ at: -1, n: 0 }); const [think, setThink] = useState({ at: -1, n: 0 }); const [fulfil, setFulfil] = useState({ at: -1, n: 0 }); const timers = useRef[]>([]); const intervals = useRef[]>([]); const clearAll = useCallback(() => { timers.current.forEach(clearTimeout); intervals.current.forEach(clearInterval); timers.current = []; intervals.current = []; }, []); useEffect(() => clearAll, [clearAll]); const request = useMemo(() => REQUESTS[index % REQUESTS.length], [index]); const wordCount = useMemo(() => request.transcript.split(" ").length, [request]); const spokenWords = spoken.at === index ? spoken.n : 0; const thinkingStep = think.at === index ? think.n : 0; const fulfilStep = fulfil.at === index ? fulfil.n : 0; // ── Auto-play orchestration (skipped under reduced motion) ── useEffect(() => { if (!running || reduce) return; clearAll(); const push = (t: ReturnType) => timers.current.push(t); const next = (s: VoiceStage, delay: number) => push(setTimeout(() => setStage(s), delay)); switch (stage) { case "home": next("tap", TIMING.home); break; case "tap": next("launch", TIMING.tap); break; case "launch": next("listening", TIMING.launch); break; case "listening": next("speaking", TIMING.listening); break; case "speaking": { // Reveal the request one word at a time, the way live dictation lands. let w = 0; const iv = setInterval(() => { w += 1; setSpoken({ at: index, n: w }); if (w >= wordCount) { clearInterval(iv); next("thinking", TIMING.afterSpeak); } }, TIMING.wordMs); intervals.current.push(iv); break; } case "thinking": for (let s = 1; s < AI_STEPS.length; s += 1) { push(setTimeout(() => setThink({ at: index, n: s }), TIMING.thinkStepMs * s)); } next( "merchant", TIMING.thinkStepMs * (AI_STEPS.length - 1) + TIMING.thinkFinalHold ); break; case "merchant": next("order", TIMING.merchant); break; case "order": next("confirmed", TIMING.order); break; case "confirmed": next("fulfilment", TIMING.confirmed); break; case "fulfilment": for (let s = 1; s < FULFILMENT_STEPS.length; s += 1) { push(setTimeout(() => setFulfil({ at: index, n: s }), TIMING.fulfilStepMs * s)); } next("delivered", TIMING.fulfilStepMs * FULFILMENT_STEPS.length); break; case "delivered": // Loop back to the mic with the *next* request — never the same one twice. push( setTimeout(() => { setIndex((i) => (i + 1) % REQUESTS.length); setStage("listening"); }, TIMING.delivered) ); break; default: break; } return clearAll; }, [stage, running, reduce, wordCount, index, clearAll]); // ── Reduced motion: report the completed end state directly ── // Derived rather than pushed through setState, so nothing ever animates or // re-renders its way there — the demo is simply already finished. const settled = reduce && running; const start = useCallback(() => setRunning(true), []); const stop = useCallback(() => { setRunning(false); clearAll(); }, [clearAll]); return { stage: settled ? "delivered" : stage, request, requests: REQUESTS, spokenWords: settled ? wordCount : spokenWords, thinkingStep: settled ? AI_STEPS.length - 1 : thinkingStep, fulfilStep: settled ? FULFILMENT_STEPS.length - 1 : fulfilStep, reduced: reduce, start, stop, }; }