update ai commerce section

This commit is contained in:
2026-07-23 10:26:49 +05:30
parent 4e0facc9af
commit a4328a791a
135 changed files with 4081 additions and 391 deletions

View 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>
);
}

View 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>
);
}

View 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}&nbsp;
</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>
);
}

View 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>
);
}