update ai commerce section
This commit is contained in:
99
src/components/home/voice/FloatingRequestCards.tsx
Normal file
99
src/components/home/voice/FloatingRequestCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
// The kinds of things people actually say to Nearle — products *and* local
|
||||
// services.
|
||||
//
|
||||
// Positions are percentages of the phone box, and every card is placed so it
|
||||
// never covers the live screen — the demo always stays readable:
|
||||
// · xl+ : three cards drift in the wide gutter left of the phone, two sit
|
||||
// clear above and below it.
|
||||
// · below xl there is no gutter, so the visible cards sit above and below
|
||||
// the device instead of beside it.
|
||||
const CARDS = [
|
||||
{
|
||||
emoji: "🥛",
|
||||
text: "Order two litres of milk",
|
||||
pos: "left-[-4%] top-[-7%] xl:left-[-50%] xl:top-[12%]",
|
||||
drift: { y: [-9, 9, -9], x: [0, 5, 0] },
|
||||
duration: 15,
|
||||
delay: 0,
|
||||
always: true,
|
||||
},
|
||||
{
|
||||
emoji: "💊",
|
||||
text: "Order medicine for headache",
|
||||
pos: "right-[-4%] bottom-[-6%] xl:right-[-6%] xl:bottom-[-7%]",
|
||||
drift: { y: [-10, 7, -10], x: [0, -5, 0] },
|
||||
duration: 16,
|
||||
delay: 2.1,
|
||||
always: true,
|
||||
},
|
||||
{
|
||||
emoji: "🍅",
|
||||
text: "Need ingredients for biryani",
|
||||
pos: "right-[-6%] top-[-9%]",
|
||||
drift: { y: [8, -8, 8], x: [0, -6, 0] },
|
||||
duration: 18,
|
||||
delay: 1.4,
|
||||
always: false,
|
||||
},
|
||||
{
|
||||
emoji: "🔧",
|
||||
text: "Book an electrician tomorrow",
|
||||
pos: "left-[-56%] top-[45%]",
|
||||
drift: { y: [7, -10, 7], x: [0, 6, 0] },
|
||||
duration: 20,
|
||||
delay: 0.7,
|
||||
always: false,
|
||||
},
|
||||
{
|
||||
emoji: "🥣",
|
||||
text: "Need breakfast for four",
|
||||
pos: "left-[-52%] top-[77%]",
|
||||
drift: { y: [9, -7, 9], x: [0, 4, 0] },
|
||||
duration: 19,
|
||||
delay: 1,
|
||||
always: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Soft glass cards drifting around the phone. Very slow, very quiet — they
|
||||
// should read as ambient context, never as animation.
|
||||
export function FloatingRequestCards() {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-20">
|
||||
{CARDS.map((card, i) => (
|
||||
<motion.div
|
||||
key={card.text}
|
||||
className={`absolute ${card.pos} ${card.always ? "" : "hidden xl:block"}`}
|
||||
initial={{ opacity: 0, y: 14, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.7, delay: 0.9 + i * 0.12, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
animate={reduced ? undefined : card.drift}
|
||||
transition={{
|
||||
duration: card.duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: card.delay,
|
||||
}}
|
||||
style={{ willChange: "transform" }}
|
||||
className="flex items-center gap-2 rounded-2xl border border-[#7C3AED]/12 bg-white/75 py-2 pl-2 pr-3.5 shadow-[0_12px_34px_-10px_rgba(76,20,140,0.28)] backdrop-blur-xl"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[#7C3AED]/[0.08] text-[13px]">
|
||||
{card.emoji}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[11.5px] font-bold text-[#16001D]">
|
||||
{card.text}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/home/voice/PhoneStage.tsx
Normal file
130
src/components/home/voice/PhoneStage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { VoiceScreen, SCREEN_W, SCREEN_H } from "./VoiceScreen";
|
||||
import { useVoiceJourney } from "@/hooks/useVoiceJourney";
|
||||
|
||||
// Geometry read straight off the provided SVG (viewBox 0 0 476 941):
|
||||
// screen rect x42 y8 w391 h826 rx57. The frame artwork never changes — only
|
||||
// what we render inside this box does.
|
||||
const SCREEN = {
|
||||
left: `${((42 / 476) * 100).toFixed(4)}%`,
|
||||
top: `${((8 / 941) * 100).toFixed(4)}%`,
|
||||
width: `${((391 / 476) * 100).toFixed(4)}%`,
|
||||
height: `${((826 / 941) * 100).toFixed(4)}%`,
|
||||
// rx57 expressed against the screen's own box: 57/391 wide, 57/826 tall
|
||||
borderRadius: `${((57 / 391) * 100).toFixed(2)}% / ${((57 / 826) * 100).toFixed(2)}%`,
|
||||
} as const;
|
||||
|
||||
export function PhoneStage({ className = "" }: { className?: string }) {
|
||||
const journey = useVoiceJourney();
|
||||
const { start, stop, reduced } = journey;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(0);
|
||||
|
||||
// The demo only runs while it is on screen — no timers burning in the
|
||||
// background once a visitor has scrolled past the hero.
|
||||
const inView = useInView(rootRef, { amount: 0.25 });
|
||||
useEffect(() => {
|
||||
if (inView) start();
|
||||
else stop();
|
||||
}, [inView, start, stop]);
|
||||
|
||||
// The screen UI is authored at the SVG's own 391×826 units, then scaled to
|
||||
// whatever size the phone renders at. Same layout everywhere, every
|
||||
// breakpoint — scaling only.
|
||||
useLayoutEffect(() => {
|
||||
const el = screenRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setScale(el.clientWidth / SCREEN_W);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`relative mx-auto ${className}`}
|
||||
style={{ perspective: 1600 }}
|
||||
>
|
||||
{/* Breathing ground shadow — a separate blurred ellipse so nothing
|
||||
animates box-shadow (which repaints every frame). */}
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 bottom-[3%] h-[9%] w-[78%] -translate-x-1/2 rounded-[50%] bg-[#3B0764]/25 blur-2xl"
|
||||
animate={reduced ? undefined : { scaleX: [1, 0.9, 1], opacity: [0.5, 0.34, 0.5] }}
|
||||
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
{/* Float — vertical drift */}
|
||||
<motion.div
|
||||
animate={reduced ? undefined : { y: [-8, 8, -8] }}
|
||||
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{/* Sway — slow rotateY on a deliberately different period, so the two
|
||||
never sync into one obvious loop */}
|
||||
<motion.div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: "476 / 941", transformStyle: "preserve-3d" }}
|
||||
animate={reduced ? undefined : { rotateY: [-1.2, 1.2, -1.2] }}
|
||||
transition={{ duration: 11, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
{/* Layer 0 — the provided iPhone SVG, verbatim and permanent */}
|
||||
<Image
|
||||
src="/images/hero/iphone-frame.svg"
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
priority
|
||||
aria-hidden="true"
|
||||
className="select-none object-contain"
|
||||
/>
|
||||
|
||||
{/* Layer 1 — the live app, clipped exactly to the screen rect */}
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="absolute overflow-hidden"
|
||||
style={{
|
||||
left: SCREEN.left,
|
||||
top: SCREEN.top,
|
||||
width: SCREEN.width,
|
||||
height: SCREEN.height,
|
||||
borderRadius: SCREEN.borderRadius,
|
||||
}}
|
||||
>
|
||||
{scale > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
height: SCREEN_H,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<VoiceScreen journey={journey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer 2 — notch + status bar, extracted from the same SVG, so the
|
||||
app sits *under* the notch exactly like real iOS */}
|
||||
<Image
|
||||
src="/images/hero/iphone-chrome.svg"
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none select-none object-contain"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
527
src/components/home/voice/VoiceScreen.tsx
Normal file
527
src/components/home/voice/VoiceScreen.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { AIThinking } from "@/components/home/experience/AIThinking";
|
||||
import {
|
||||
AI_STEPS,
|
||||
FULFILMENT_STEPS,
|
||||
SERVICE_FULFILMENT_STEPS,
|
||||
type VoiceJourney,
|
||||
} from "@/hooks/useVoiceJourney";
|
||||
|
||||
// This component is authored in the provided SVG's own screen units — a
|
||||
// 391 × 826 box — and PhoneStage scales it to whatever size the phone renders
|
||||
// at. So the layout is pixel-identical on every breakpoint: responsive scaling
|
||||
// only, never a different (or simpler) UI.
|
||||
export const SCREEN_W = 391;
|
||||
export const SCREEN_H = 826;
|
||||
|
||||
// The Nearle icon inside the provided SVG: x92 y622 45×45, minus the screen's
|
||||
// own origin (42, 8). We overlay a matching icon here so it can compress under
|
||||
// the tap and the app can grow out of exactly the right spot.
|
||||
const ICON = { x: 50, y: 614, size: 45 } as const;
|
||||
const ICON_CX = ICON.x + ICON.size / 2; // 72.5
|
||||
const ICON_CY = ICON.y + ICON.size / 2; // 636.5
|
||||
// Where the app expands from, as a transform-origin on the 391×826 screen.
|
||||
const LAUNCH_ORIGIN = `${((ICON_CX / SCREEN_W) * 100).toFixed(2)}% ${((ICON_CY / SCREEN_H) * 100).toFixed(2)}%`;
|
||||
|
||||
// Clear of the notch (which ends 30px into the screen) and the status bar.
|
||||
const SAFE_TOP = 56;
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
|
||||
// Panels are stacked absolutely and crossfade, so one screen is always fully
|
||||
// covering the other — the app never flashes empty between beats.
|
||||
const screenMotion = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE, delay: 0.12 } },
|
||||
exit: { opacity: 0, y: -8, transition: { duration: 0.28, ease: "easeIn" as const } },
|
||||
};
|
||||
|
||||
export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
const {
|
||||
stage,
|
||||
request,
|
||||
spokenWords,
|
||||
thinkingStep,
|
||||
fulfilStep,
|
||||
reduced,
|
||||
} = journey;
|
||||
|
||||
// The app mounts on "launch" and never unmounts again — the loop returns to
|
||||
// "listening", not to the home screen — so the iOS expand plays exactly once.
|
||||
const onHomeScreen = stage === "home" || stage === "tap";
|
||||
const words = request.transcript.split(" ");
|
||||
const isBooking = request.domain === "services";
|
||||
// The request bubble appears the moment the AI takes over and stays put.
|
||||
const showRequest =
|
||||
stage !== "launch" && !onHomeScreen && stage !== "listening" && stage !== "speaking";
|
||||
const fulfilmentSteps = isBooking ? SERVICE_FULFILMENT_STEPS : FULFILMENT_STEPS;
|
||||
// On the final beat every fulfilment step is done — nothing left pulsing.
|
||||
const fulfilDone = stage === "delivered" ? fulfilmentSteps.length : fulfilStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden bg-transparent"
|
||||
style={{ width: SCREEN_W, height: SCREEN_H }}
|
||||
>
|
||||
{/* ── iOS home screen: the provided SVG shows through. We only overlay a
|
||||
matching Nearle icon so it can react to the tap. ── */}
|
||||
{onHomeScreen && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute rounded-[10px] bg-[#7F67BE]"
|
||||
style={{ left: ICON.x, top: ICON.y, width: ICON.size, height: ICON.size }}
|
||||
animate={
|
||||
reduced
|
||||
? undefined
|
||||
: stage === "tap"
|
||||
? { scale: 0.88, opacity: 0.75 }
|
||||
: { scale: [1, 1.06, 1], opacity: 1 }
|
||||
}
|
||||
transition={
|
||||
stage === "tap"
|
||||
? { duration: 0.35, ease: EASE }
|
||||
: { duration: 2, repeat: Infinity, ease: "easeInOut" }
|
||||
}
|
||||
>
|
||||
<span className="absolute inset-0 flex items-center justify-center font-display text-[26px] font-black leading-none text-white">
|
||||
N
|
||||
</span>
|
||||
{/* the icon's own soft highlight, matching the SVG artwork */}
|
||||
<span className="absolute inset-0 rounded-[10px] bg-white/10" />
|
||||
</motion.div>
|
||||
|
||||
{/* Apple-style touch indicator — a finger tap, never a desktop cursor */}
|
||||
{!reduced && (
|
||||
<motion.div
|
||||
className="absolute rounded-full border-2 border-white/70 bg-white/25"
|
||||
style={{ width: 34, height: 34, left: ICON_CX - 17, top: ICON_CY - 17 }}
|
||||
initial={{ opacity: 0, scale: 1.9 }}
|
||||
animate={
|
||||
stage === "home"
|
||||
? { opacity: [0, 0.55], scale: [1.9, 1.25] }
|
||||
: { opacity: [0.9, 0], scale: [0.85, 1.6] }
|
||||
}
|
||||
transition={{ duration: stage === "home" ? 1.1 : 0.5, ease: EASE }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── The Nearle app ── */}
|
||||
{!onHomeScreen && (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex flex-col overflow-hidden text-white"
|
||||
style={{
|
||||
transformOrigin: LAUNCH_ORIGIN,
|
||||
background:
|
||||
"radial-gradient(120% 70% at 50% 0%, #2A0846 0%, #180329 45%, #0D0116 100%)",
|
||||
}}
|
||||
initial={
|
||||
reduced ? false : { scale: ICON.size / SCREEN_W, opacity: 0, borderRadius: 88 }
|
||||
}
|
||||
animate={{ scale: 1, opacity: 1, borderRadius: 0 }}
|
||||
transition={{ duration: 0.62, ease: EASE }}
|
||||
>
|
||||
{/* ambient purple bloom */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-24 left-1/2 h-[320px] w-[320px] -translate-x-1/2 rounded-full opacity-60 blur-[70px]"
|
||||
style={{ background: "radial-gradient(circle, #A855F7 0%, transparent 70%)" }}
|
||||
/>
|
||||
|
||||
{/* App header */}
|
||||
<div
|
||||
className="relative flex shrink-0 items-center justify-between px-6"
|
||||
style={{ paddingTop: SAFE_TOP }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-[9px] bg-[#7F67BE] font-display text-[15px] font-black leading-none">
|
||||
N
|
||||
</span>
|
||||
<span className="font-display text-[15px] font-black tracking-tight">Nearle</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[10px] font-bold text-white/80">
|
||||
<MaterialIcon icon="location_on" size={11} />
|
||||
R.S. Puram
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* What the customer said, kept on screen for the rest of the flow —
|
||||
so the link between the sentence and everything the AI does with
|
||||
it stays visible the whole way to the door. */}
|
||||
{showRequest && (
|
||||
<motion.div
|
||||
initial={reduced ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: EASE }}
|
||||
className="relative flex shrink-0 justify-end px-6 pt-5"
|
||||
>
|
||||
<div className="max-w-[82%] rounded-[18px] rounded-br-[6px] bg-gradient-to-br from-[#A855F7] to-[#7C3AED] px-3.5 py-2.5 shadow-[0_8px_22px_-8px_rgba(168,85,247,0.8)]">
|
||||
<span className="mb-0.5 flex items-center justify-end gap-1 text-[8.5px] font-black uppercase tracking-[0.15em] text-white/60">
|
||||
<MaterialIcon icon="mic" size={9} />
|
||||
You said
|
||||
</span>
|
||||
<p className="text-[12.5px] font-bold leading-snug">{request.transcript}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Screen body */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<AnimatePresence initial={false}>
|
||||
{(stage === "launch" || stage === "listening" || stage === "speaking") && (
|
||||
<motion.div
|
||||
key="voice"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col items-center justify-center px-7"
|
||||
>
|
||||
<MicOrb reduced={reduced} active={stage === "speaking"} />
|
||||
|
||||
<p className="mt-9 text-[11px] font-black uppercase tracking-[0.2em] text-[#C9A2FF]">
|
||||
{stage === "speaking" ? "Listening" : "Listening…"}
|
||||
</p>
|
||||
|
||||
{/* The spoken request, landing word by word */}
|
||||
<div className="mt-3 flex min-h-[62px] w-full items-start justify-center">
|
||||
{stage === "speaking" ? (
|
||||
<p className="text-center font-display text-[21px] font-black leading-[1.28] tracking-tight">
|
||||
{words.map((w, i) => (
|
||||
<motion.span
|
||||
key={`${request.id}-${i}`}
|
||||
initial={reduced ? false : { opacity: 0, y: 6 }}
|
||||
animate={
|
||||
i < spokenWords ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }
|
||||
}
|
||||
transition={{ duration: 0.28, ease: EASE }}
|
||||
className="inline-block"
|
||||
>
|
||||
{w}
|
||||
</motion.span>
|
||||
))}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-center text-[13px] font-semibold leading-relaxed text-white/55">
|
||||
Speak naturally.
|
||||
<br />
|
||||
No apps, no searching.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Waveform reduced={reduced} active={stage === "speaking"} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === "thinking" && (
|
||||
<motion.div
|
||||
key="thinking"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
|
||||
>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.06] p-5 backdrop-blur-sm">
|
||||
<p className="mb-4 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
|
||||
<MaterialIcon icon="auto_awesome" size={13} />
|
||||
Nearle AI
|
||||
</p>
|
||||
<AIThinking steps={AI_STEPS} activeStep={thinkingStep} tone="dark" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === "merchant" && (
|
||||
<motion.div
|
||||
key="merchant"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
|
||||
>
|
||||
<p className="mb-3 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
|
||||
<MaterialIcon icon="near_me" size={13} />
|
||||
Found nearby
|
||||
</p>
|
||||
<motion.div
|
||||
initial={reduced ? false : { opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.55, ease: EASE }}
|
||||
className="rounded-[22px] border border-white/12 bg-white/[0.07] p-5"
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[#A855F7]/20 text-[#C9A2FF]">
|
||||
<MaterialIcon icon={request.merchant.icon} size={24} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-display text-[16px] font-black leading-tight tracking-tight">
|
||||
{request.merchant.name}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] font-semibold text-white/55">
|
||||
{request.merchant.area} · {request.merchant.distance}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className="flex items-center gap-1 rounded-full bg-emerald-400/15 px-2.5 py-1 text-[10px] font-black text-emerald-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Open now
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[10px] font-black text-white/80">
|
||||
<MaterialIcon icon="star" size={11} fill />
|
||||
{request.merchant.rating}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2.5 py-1 text-[10px] font-black text-white/80">
|
||||
Verified
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === "order" && (
|
||||
<motion.div
|
||||
key="order"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
|
||||
>
|
||||
<p className="mb-2.5 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
|
||||
<MaterialIcon icon="auto_awesome" size={13} />
|
||||
{isBooking ? "Booking created" : "Order created"}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] font-bold text-white/70">
|
||||
<MaterialIcon icon={request.merchant.icon} size={13} />
|
||||
<span className="truncate">{request.merchant.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{request.items.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.name}
|
||||
initial={reduced ? false : { opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: reduced ? 0 : 0.1 + i * 0.13, duration: 0.45, ease: EASE }}
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-2.5"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/[0.07] text-[17px]">
|
||||
{item.emoji}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-black leading-tight">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-white/50">
|
||||
{item.qty}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-[9.5px] font-black text-emerald-300">
|
||||
<span className="h-1 w-1 rounded-full bg-emerald-400" />
|
||||
{item.status}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[13px] font-black">₹{item.price}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 shrink-0 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-white/50">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-display text-[21px] font-black leading-none">
|
||||
₹{request.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-[10px] font-bold text-white/50">
|
||||
<MaterialIcon icon="schedule" size={11} />
|
||||
{request.eta}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm auto-taps at the end of this beat */}
|
||||
<motion.div
|
||||
animate={reduced ? undefined : { scale: [1, 1, 0.95, 1] }}
|
||||
transition={{ duration: 2.6, times: [0, 0.76, 0.84, 0.92], ease: EASE }}
|
||||
className="mt-3 flex shrink-0 items-center justify-center gap-1.5 rounded-full bg-white py-3.5 text-[13px] font-black text-[#2A0846]"
|
||||
>
|
||||
<MaterialIcon icon="bolt" size={15} />
|
||||
{request.cta}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === "confirmed" && (
|
||||
<motion.div
|
||||
key="confirmed"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col items-center justify-center px-8"
|
||||
>
|
||||
<motion.div
|
||||
initial={reduced ? false : { scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 18 }}
|
||||
className="relative flex h-[86px] w-[86px] items-center justify-center rounded-full bg-emerald-400/15"
|
||||
>
|
||||
{!reduced && (
|
||||
<motion.span
|
||||
className="absolute inset-0 rounded-full border-2 border-emerald-400/50"
|
||||
animate={{ scale: [1, 1.6], opacity: [0.7, 0] }}
|
||||
transition={{ duration: 1.1, repeat: Infinity, ease: "easeOut" }}
|
||||
/>
|
||||
)}
|
||||
<MaterialIcon icon="check" size={44} className="text-emerald-300" />
|
||||
</motion.div>
|
||||
<p className="mt-6 text-center font-display text-[20px] font-black tracking-tight">
|
||||
{isBooking ? "Booking confirmed" : "Order confirmed"}
|
||||
</p>
|
||||
<p className="mt-2 text-center text-[12px] font-semibold text-white/55">
|
||||
Sent to {request.merchant.name}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{(stage === "fulfilment" || stage === "delivered") && (
|
||||
<motion.div
|
||||
key="fulfilment"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
|
||||
>
|
||||
<p className="mb-4 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-emerald-300">
|
||||
<MaterialIcon icon="two_wheeler" size={13} />
|
||||
{stage === "delivered"
|
||||
? isBooking
|
||||
? "Job completed"
|
||||
: "Delivered"
|
||||
: "On the way"}
|
||||
</p>
|
||||
|
||||
<div className="relative space-y-3.5 pl-1">
|
||||
{/* connector rail */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[13px] top-3 bottom-3 w-px bg-white/12"
|
||||
/>
|
||||
{fulfilmentSteps.map((step, i) => {
|
||||
const done = i < fulfilDone;
|
||||
const active = i === fulfilDone;
|
||||
return (
|
||||
<div
|
||||
key={step.label}
|
||||
className={`relative flex items-center gap-3 transition-opacity duration-300 ${
|
||||
i <= fulfilDone ? "opacity-100" : "opacity-35"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`relative z-10 flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full ${
|
||||
done || active
|
||||
? "bg-emerald-400/20 text-emerald-300"
|
||||
: "bg-white/10 text-white/60"
|
||||
}`}
|
||||
>
|
||||
{done ? (
|
||||
<MaterialIcon icon="check" size={14} />
|
||||
) : active && !reduced ? (
|
||||
<motion.span
|
||||
className="h-2 w-2 rounded-full bg-emerald-400"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [1, 0.5, 1] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon icon={step.icon} size={13} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[12.5px] font-black">{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-white/45">
|
||||
{isBooking ? "Professional from" : "Packed by"}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-display text-[13.5px] font-black">
|
||||
{request.merchant.name}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Persistent mic bar — the app is always one sentence away */}
|
||||
<div className="relative shrink-0 px-6 pb-7">
|
||||
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-white/[0.07] py-2.5 pl-3 pr-4">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#A855F7] to-[#7C3AED]">
|
||||
<MaterialIcon icon="mic" size={16} className="text-white" />
|
||||
</span>
|
||||
<span className="truncate text-[11.5px] font-bold text-white/55">
|
||||
{stage === "listening" ? "Listening…" : "Ask Nearle anything"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The listening orb: a soft purple core with breathing rings.
|
||||
function MicOrb({ reduced, active }: { reduced: boolean; active: boolean }) {
|
||||
return (
|
||||
<div className="relative flex h-[104px] w-[104px] items-center justify-center">
|
||||
{!reduced &&
|
||||
[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="absolute inset-0 rounded-full border border-[#C9A2FF]/40"
|
||||
animate={{ scale: [1, active ? 1.75 : 1.5], opacity: [0.55, 0] }}
|
||||
transition={{
|
||||
duration: active ? 1.8 : 2.4,
|
||||
repeat: Infinity,
|
||||
ease: "easeOut",
|
||||
delay: i * (active ? 0.6 : 0.8),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<motion.span
|
||||
className="flex h-[68px] w-[68px] items-center justify-center rounded-full shadow-[0_10px_40px_rgba(168,85,247,0.45)]"
|
||||
style={{ background: "linear-gradient(145deg,#C084FC 0%,#7C3AED 100%)" }}
|
||||
animate={reduced ? undefined : { scale: active ? [1, 1.06, 1] : [1, 1.03, 1] }}
|
||||
transition={{ duration: active ? 0.9 : 2.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<MaterialIcon icon="mic" size={30} className="text-white" />
|
||||
</motion.span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Live voice bars. Heights are fixed per bar so the pattern reads as a real
|
||||
// waveform rather than random noise; only scaleY animates.
|
||||
const BARS = [0.35, 0.6, 0.85, 1, 0.7, 0.45, 0.8, 1, 0.65, 0.4, 0.75, 0.55, 0.3];
|
||||
|
||||
function Waveform({ reduced, active }: { reduced: boolean; active: boolean }) {
|
||||
return (
|
||||
<div className="mt-7 flex h-9 items-center justify-center gap-[5px]">
|
||||
{BARS.map((h, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-[3px] rounded-full bg-gradient-to-t from-[#7C3AED] to-[#C9A2FF]"
|
||||
style={{ height: 36 * h, originY: 0.5 }}
|
||||
animate={
|
||||
reduced
|
||||
? { scaleY: 0.5 }
|
||||
: { scaleY: active ? [0.32, 1, 0.5, 0.85, 0.32] : [0.22, 0.42, 0.22] }
|
||||
}
|
||||
transition={{
|
||||
duration: active ? 1.1 : 1.9,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: i * 0.07,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/home/voice/VoiceWaves.tsx
Normal file
66
src/components/home/voice/VoiceWaves.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
// A 2400-wide path holding six 400px periods. Sliding it exactly -1200
|
||||
// (three periods) loops seamlessly, so the drift never visibly restarts.
|
||||
const WAVE =
|
||||
"M0 150 Q100 80 200 150 T400 150 T600 150 T800 150 T1000 150 T1200 150 T1400 150 T1600 150 T1800 150 T2000 150 T2200 150 T2400 150";
|
||||
|
||||
const LAYERS = [
|
||||
{ y: 0, scaleY: 1, opacity: 0.18, duration: 12, width: 2 },
|
||||
{ y: 26, scaleY: 0.62, opacity: 0.13, duration: 16, width: 1.5 },
|
||||
{ y: -22, scaleY: 1.35, opacity: 0.1, duration: 21, width: 1.5 },
|
||||
];
|
||||
|
||||
// Elegant voice waves behind the phone. Purely decorative, low opacity, and
|
||||
// animated on transform only — it should reinforce "this is voice" without
|
||||
// ever pulling attention off the screen.
|
||||
export function VoiceWaves({ className = "" }: { className?: string }) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-0 overflow-hidden ${className}`}
|
||||
style={{
|
||||
maskImage: "radial-gradient(60% 55% at 50% 50%, #000 35%, transparent 100%)",
|
||||
WebkitMaskImage: "radial-gradient(60% 55% at 50% 50%, #000 35%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
{LAYERS.map((layer, i) => (
|
||||
<motion.svg
|
||||
key={i}
|
||||
viewBox="0 0 2400 300"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute left-1/2 top-1/2 h-[52%] w-[200%]"
|
||||
style={{
|
||||
x: "-50%",
|
||||
y: `calc(-50% + ${layer.y}px)`,
|
||||
opacity: layer.opacity,
|
||||
}}
|
||||
animate={reduced ? undefined : { translateX: ["0%", "-25%"] }}
|
||||
transition={{ duration: layer.duration, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`voice-wave-${i}`} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#7C3AED" stopOpacity="0" />
|
||||
<stop offset="35%" stopColor="#A855F7" stopOpacity="1" />
|
||||
<stop offset="65%" stopColor="#C084FC" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#7C3AED" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={WAVE}
|
||||
fill="none"
|
||||
stroke={`url(#voice-wave-${i})`}
|
||||
strokeWidth={layer.width}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
transform={`translate(0 ${150 - 150 * layer.scaleY}) scale(1 ${layer.scaleY})`}
|
||||
/>
|
||||
</motion.svg>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user