215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
"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<VoiceStage>("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<ReturnType<typeof setTimeout>[]>([]);
|
|
const intervals = useRef<ReturnType<typeof setInterval>[]>([]);
|
|
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<typeof setTimeout>) => 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,
|
|
};
|
|
}
|