update ai commerce section
This commit is contained in:
@@ -13,7 +13,7 @@ export function FloatingDelivery() {
|
||||
transition={{ duration: 6, ease: "easeInOut", repeat: Infinity }}
|
||||
>
|
||||
<Image
|
||||
src="/images/nearle-redesign/delivery-doorstep.webp"
|
||||
src="/images/illustrations/delivery-doorstep.webp"
|
||||
alt=""
|
||||
width={220}
|
||||
height={220}
|
||||
|
||||
@@ -427,7 +427,7 @@ export function BusinessFeaturesGrid() {
|
||||
>
|
||||
<div className="relative h-[88%] w-full -translate-x-[2%]">
|
||||
<Image
|
||||
src="/superhero.png"
|
||||
src="/images/illustrations/superhero.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="600px"
|
||||
|
||||
@@ -125,7 +125,7 @@ export function PremiumFooter() {
|
||||
<motion.div variants={fadeUp} className="flex flex-col gap-5">
|
||||
<Link href="/" className="inline-block w-fit">
|
||||
<Image
|
||||
src="/logo-light.png"
|
||||
src="/logos/logo-light.png"
|
||||
alt="Nearle"
|
||||
width={240}
|
||||
height={60}
|
||||
@@ -40,7 +40,7 @@ export function AboutHero() {
|
||||
|
||||
{/* Background image — scooter anchored to the right */}
|
||||
<Image
|
||||
src="/about.webp"
|
||||
src="/images/hero/about.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -40,7 +40,7 @@ export function BrandsHero() {
|
||||
transition={{ duration: 3, ease: "easeOut" }}
|
||||
>
|
||||
<Image
|
||||
src="/Gemini_Generated_Image_lxh416lxh416lxh4.png"
|
||||
src="/images/illustrations/gemini-generated-image.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function BusinessHero() {
|
||||
<section className="w-full flex flex-col select-none bg-white">
|
||||
<div className="relative overflow-hidden border-b border-[#6330D6]/10 bg-[#FAF8FC]">
|
||||
<Image
|
||||
src="/images/business1.webp"
|
||||
src="/images/hero/business1.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ContactHero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-[#6330D6]/10 bg-[#FAF8FC] min-h-[560px] md:min-h-[85vh] px-4 py-10 md:px-6 md:py-12 lg:px-8 lg:py-14 xl:px-10 xl:py-16 2xl:px-12 2xl:py-20">
|
||||
<Image
|
||||
src="/contact-page1.webp"
|
||||
src="/images/hero/contact-page1.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DeliveryHero() {
|
||||
<section className="w-full flex flex-col select-none bg-white">
|
||||
<div className="relative overflow-hidden border-b border-[#6330D6]/10 bg-[#FAF8FC]">
|
||||
<Image
|
||||
src="/delivery.png"
|
||||
src="/images/workflow/delivery.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -51,7 +51,7 @@ export function HeroLogo({ align = "center", spacing = "default" }: HeroLogoProp
|
||||
transition={{ duration: 1, delay: 0.2, ease: EASE_LOGO }}
|
||||
>
|
||||
<Image
|
||||
src="/nerale (1).png"
|
||||
src="/images/illustrations/nerale.png"
|
||||
alt="Nearle"
|
||||
width={465}
|
||||
height={384}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function MerchantsHero() {
|
||||
<section className="w-full flex flex-col select-none bg-white">
|
||||
<div className="relative overflow-hidden border-b border-[#6330D6]/10 bg-[#FAF8FC]">
|
||||
<Image
|
||||
src="/merchant.png"
|
||||
src="/images/stores/merchant.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function PeopleHero() {
|
||||
<section className="w-full flex flex-col select-none bg-white">
|
||||
<div className="relative overflow-hidden border-b border-[#6330D6]/10 bg-[#FAF8FC]">
|
||||
<Image
|
||||
src="/images/people1.webp"
|
||||
src="/images/hero/people1.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
126
src/components/home/AICommerceTeaser.tsx
Normal file
126
src/components/home/AICommerceTeaser.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { fadeUp, staggerContainer, staggerCards, viewport, viewport20 } from "@/lib/animations";
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
|
||||
const ROWS = [
|
||||
{ traditional: "Manual discovery", nearle: "AI discovery" },
|
||||
{ traditional: "Walk into stores", nearle: "Search nearby instantly" },
|
||||
{ traditional: "Limited visibility", nearle: "Every local merchant discoverable" },
|
||||
{ traditional: "Static catalogue", nearle: "AI-organized catalogue" },
|
||||
];
|
||||
|
||||
const rowVariant = {
|
||||
hidden: { opacity: 0, y: 14 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE } },
|
||||
};
|
||||
|
||||
// Priority 4: a compact teaser contrasting traditional commerce with Nearle's
|
||||
// AI commerce. The full seven-row comparison lives on /why-nearle.
|
||||
export function AICommerceTeaser() {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section className="section section-soft w-full overflow-hidden border-y border-[#6330D6]/10">
|
||||
<div className="page-container">
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mx-auto max-w-[680px] text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="inline-flex items-center rounded-full bg-[#6330D6]/[0.08] px-3.5 py-1 text-xs font-black uppercase tracking-widest text-[#6330D6]"
|
||||
>
|
||||
The Shift
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="mt-4 font-display font-black tracking-tight text-[#16001D]"
|
||||
style={{ fontSize: "clamp(28px, 3vw, 44px)", lineHeight: 1.1 }}
|
||||
>
|
||||
Traditional commerce, reimagined by AI
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="mx-auto mt-10 max-w-[860px]">
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-2 gap-3 px-1 sm:gap-4">
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-[#6D6172] sm:text-xs">
|
||||
Traditional Commerce
|
||||
</span>
|
||||
<span className="pl-2 text-[11px] font-black uppercase tracking-widest text-[#6330D6] sm:text-xs">
|
||||
Nearle AI Commerce
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={staggerCards}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport20}
|
||||
className="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
{ROWS.map((row) => (
|
||||
<motion.div
|
||||
key={row.nearle}
|
||||
variants={rowVariant}
|
||||
className="grid grid-cols-2 gap-3 sm:gap-4"
|
||||
>
|
||||
{/* Traditional side */}
|
||||
<div className="flex items-center gap-2.5 rounded-2xl border border-[#6330D6]/8 bg-white px-4 py-3.5 shadow-[0_4px_16px_rgba(22,0,29,0.03)]">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#F3F3F6] text-[#6D6172]">
|
||||
<MaterialIcon icon="remove" size={14} />
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-[#5E5866] sm:text-[15px]">
|
||||
{row.traditional}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nearle side */}
|
||||
<div className="flex items-center gap-2.5 rounded-2xl border border-[#6330D6]/20 bg-[linear-gradient(135deg,#ffffff_0%,#fbf7ff_55%,#f1e8fb_100%)] px-4 py-3.5 shadow-[0_4px_20px_rgba(99,48,214,0.08)]">
|
||||
<motion.span
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#DCFCE7] text-[#16A34A]"
|
||||
animate={reduce ? {} : { scale: [1, 1.12, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 2.4, ease: "easeInOut" }}
|
||||
>
|
||||
<MaterialIcon icon="check" size={13} />
|
||||
</motion.span>
|
||||
<span className="text-[13px] font-bold text-[#16001D] sm:text-[15px]">
|
||||
{row.nearle}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mt-8 flex justify-center"
|
||||
>
|
||||
<Link
|
||||
href="/why-nearle"
|
||||
className="group inline-flex items-center gap-2 text-base font-black text-[#6330D6] transition-colors hover:text-[#4C1D95]"
|
||||
>
|
||||
Learn More
|
||||
<MaterialIcon
|
||||
icon="arrow_forward"
|
||||
size={20}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const FEATURES = [
|
||||
title: "AI Catalogue",
|
||||
desc: "Turn your shelf into a live online catalogue with ProductLLM — no manual data entry.",
|
||||
icon: "menu_book",
|
||||
visual: "/images/nearle-merchant/catalogue-ui.webp",
|
||||
visual: "/images/stores/catalogue-ui.webp",
|
||||
alt: "Clean grocery catalogue interface preview",
|
||||
tone: "bg-primary/8 text-primary",
|
||||
},
|
||||
@@ -19,7 +19,7 @@ const FEATURES = [
|
||||
title: "QR Ordering",
|
||||
desc: "Let customers scan, browse, and place orders directly from your store.",
|
||||
icon: "qr_code",
|
||||
visual: "/images/nearle-merchant/qr-stand.webp",
|
||||
visual: "/images/stores/qr-stand.webp",
|
||||
alt: "QR ordering stand on a local grocery store counter",
|
||||
tone: "bg-secondary/8 text-secondary",
|
||||
},
|
||||
@@ -27,7 +27,7 @@ const FEATURES = [
|
||||
title: "Local Discovery",
|
||||
desc: "Get discovered by nearby customers searching for products you sell.",
|
||||
icon: "location_on",
|
||||
visual: "/images/nearle-merchant/local-map.webp",
|
||||
visual: "/images/stores/local-map.webp",
|
||||
alt: "Local discovery map with purple pins and a store listing",
|
||||
tone: "bg-[#de96f9]/10 text-primary",
|
||||
},
|
||||
@@ -35,7 +35,7 @@ const FEATURES = [
|
||||
title: "Delivery Support",
|
||||
desc: "Deliver orders easily with your own staff or trusted local delivery partners.",
|
||||
icon: "local_shipping",
|
||||
visual: "/images/nearle-merchant/delivery-support.webp",
|
||||
visual: "/images/stores/delivery-support.webp",
|
||||
alt: "Local delivery partner handing a grocery bag to a customer",
|
||||
tone: "bg-rose-500/5 text-rose-600",
|
||||
},
|
||||
@@ -105,7 +105,7 @@ function LocalDiscoveryVisual() {
|
||||
{/* Store Card (Right Side) */}
|
||||
<div className="absolute bottom-4 right-4 z-30 w-[200px] overflow-hidden rounded-[1.25rem] bg-white shadow-[0_12px_32px_rgba(22,0,29,0.12)] border border-primary/10 sm:bottom-6 sm:right-6 sm:w-[240px]">
|
||||
<div className="relative h-[90px] w-full bg-brand-accent-light sm:h-[110px]">
|
||||
<Image src="/images/nearle-merchant/success-owner.webp" alt="Owner" fill className="object-cover" />
|
||||
<Image src="/images/stores/success-owner.webp" alt="Owner" fill className="object-cover" />
|
||||
</div>
|
||||
<div className="p-3.5 sm:p-4">
|
||||
<p className="truncate text-[13px] font-black text-brand-dark sm:text-[15px]">Kumar Provision Store</p>
|
||||
@@ -279,7 +279,7 @@ export function BusinessSection() {
|
||||
>
|
||||
<div className="relative min-h-[128px] overflow-hidden bg-brand-accent-light">
|
||||
<Image
|
||||
src="/images/nearle-merchant/success-owner.webp"
|
||||
src="/images/stores/success-owner.webp"
|
||||
alt="Indian grocery shop owner using a smartphone inside a kirana store"
|
||||
fill
|
||||
sizes="100px"
|
||||
|
||||
@@ -89,7 +89,7 @@ export function Hero() {
|
||||
transition={{ duration: 3, ease: "easeOut" }}
|
||||
>
|
||||
<Image
|
||||
src="/images/home1.webp"
|
||||
src="/images/hero/home1.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -3,37 +3,8 @@
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import {
|
||||
fadeUp,
|
||||
scaleIn,
|
||||
staggerContainer,
|
||||
slideInLeft,
|
||||
viewport,
|
||||
} from "@/lib/animations";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
num: "01",
|
||||
icon: "storefront",
|
||||
title: "Get Online",
|
||||
desc: "Introduce your business to your Neighbourhood. Compete with online businesses through your offline store.",
|
||||
visual: "store",
|
||||
},
|
||||
{
|
||||
num: "02",
|
||||
icon: "campaign",
|
||||
title: "Get Noticed",
|
||||
desc: "Your products get published to Nearle customers in your neighbourhood through the app. Customers search based on location, products, discounts, and promos.",
|
||||
visual: "phone",
|
||||
},
|
||||
{
|
||||
num: "03",
|
||||
icon: "auto_awesome",
|
||||
title: "Get Simplified",
|
||||
desc: "Delight the community with a personalized online experience. Unlock revenue opportunities and marketing strategies that help your business grow further.",
|
||||
visual: "bag",
|
||||
},
|
||||
];
|
||||
import { fadeUp, scaleIn, slideInLeft, viewport } from "@/lib/animations";
|
||||
import { MerchantJourney } from "@/components/home/merchant/MerchantJourney";
|
||||
|
||||
function OrdersCard() {
|
||||
return (
|
||||
@@ -109,169 +80,9 @@ function GrowthPill() {
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneMapVisual() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="absolute bottom-1 h-6 w-32 rounded-full bg-brand-dark/20 blur-xl" />
|
||||
|
||||
{/* REALISTIC MOBILE DEVICE FRAME */}
|
||||
<div className="relative aspect-[140/280] h-[95%] min-h-[240px] max-h-[280px] rounded-[24px] bg-[linear-gradient(145deg,#d1d5db_0%,#4b5563_15%,#111827_85%,#4b5563_100%)] p-[2px] shadow-[0_20px_50px_rgba(22,0,29,0.25),0_0_0_1px_rgba(0,0,0,0.1),inset_0_0_2px_rgba(255,255,255,0.4)]">
|
||||
|
||||
{/* Hardware Buttons */}
|
||||
<div className="absolute -left-[2px] top-[18%] h-4 w-[2px] rounded-l bg-gradient-to-r from-[#9ca3af] to-[#374151] shadow-sm" />
|
||||
<div className="absolute -left-[2px] top-[26%] h-7 w-[2px] rounded-l bg-gradient-to-r from-[#9ca3af] to-[#374151] shadow-sm" />
|
||||
<div className="absolute -left-[2px] top-[34%] h-7 w-[2px] rounded-l bg-gradient-to-r from-[#9ca3af] to-[#374151] shadow-sm" />
|
||||
<div className="absolute -right-[2px] top-[28%] h-9 w-[2px] rounded-r bg-gradient-to-l from-[#9ca3af] to-[#374151] shadow-sm" />
|
||||
|
||||
{/* Inner Black Bezel */}
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[22px] bg-black p-[4px] shadow-[inset_0_0_4px_rgba(255,255,255,0.1)]">
|
||||
|
||||
{/* Inner Screen */}
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[18px] bg-[#FAF8FC] shadow-[inset_0_0_8px_rgba(0,0,0,0.05)]">
|
||||
|
||||
{/* Dynamic Island */}
|
||||
<div className="absolute left-1/2 top-1.5 z-50 flex h-[12px] w-[38px] -translate-x-1/2 items-center justify-end rounded-full bg-black px-1 shadow-[inset_0_-1px_1px_rgba(255,255,255,0.1)] ring-[0.5px] ring-white/10">
|
||||
<div className="relative h-[6px] w-[6px] overflow-hidden rounded-full bg-[#050515] shadow-[inset_0_0_1px_rgba(255,255,255,0.2)]">
|
||||
<div className="absolute right-0 top-0 h-[3px] w-[3px] rounded-full bg-blue-400/30 blur-[0.5px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="absolute inset-x-0 top-0 z-40 flex items-center justify-between px-3 pt-2 text-[5px] font-black text-brand-dark">
|
||||
<span className="tracking-tighter">9:41</span>
|
||||
<div className="flex items-center gap-[2px] opacity-80">
|
||||
{/* Signal */}
|
||||
<div className="flex items-end gap-[0.5px]">
|
||||
<div className="h-[3px] w-[1px] rounded-full bg-brand-dark" />
|
||||
<div className="h-[4px] w-[1px] rounded-full bg-brand-dark" />
|
||||
<div className="h-[5px] w-[1px] rounded-full bg-brand-dark" />
|
||||
<div className="h-[6px] w-[1px] rounded-full bg-brand-dark/30" />
|
||||
</div>
|
||||
{/* Wifi */}
|
||||
<svg width="6" height="6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21C11.5 21 11.1 20.9 10.7 20.6C8 18.2 4.4 17.5 1.4 18.3C0.8 18.5 0.2 18.1 0 17.5C-0.2 16.9 0.2 16.3 0.8 16.1C4.8 15 9.4 15.9 12.8 18.9C13.2 19.3 13.3 19.9 12.9 20.3C12.7 20.8 12.3 21 12 21Z" />
|
||||
<path d="M12 16C11.6 16 11.2 15.9 10.9 15.7C5.3 12.2 0.4 14 0.3 14C-0.3 14.2 -0.8 13.9 -0.9 13.3C-1.1 12.7 -0.8 12.2 -0.2 12.1C0.2 11.9 5.9 9.8 12 13.7C12.5 14 12.6 14.6 12.3 15.1C12.2 15.7 12 16 12 16Z" opacity="0.7"/>
|
||||
</svg>
|
||||
{/* Battery */}
|
||||
<div className="relative flex h-[5px] w-[10px] items-center rounded-[1.5px] border-[1px] border-brand-dark/80 p-[0.5px]">
|
||||
<div className="h-full w-[80%] rounded-[0.5px] bg-brand-dark" />
|
||||
<div className="absolute -right-[1.5px] top-1/2 h-[2px] w-[1px] -translate-y-1/2 rounded-r-[0.5px] bg-brand-dark/80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Visual Content */}
|
||||
<div className="absolute inset-0 top-3 bg-[linear-gradient(145deg,#FAF8FC,#F1E7F8)]">
|
||||
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 140 255" fill="none" aria-hidden="true" preserveAspectRatio="xMidYMid slice">
|
||||
<rect width="140" height="255" fill="#FAF8FC" />
|
||||
<path d="M-20 50C12 40 41 42 64 54C91 68 112 64 158 48" stroke="#E3D9EA" strokeWidth="8" strokeLinecap="round" />
|
||||
<path d="M-18 178C24 158 51 164 78 179C102 192 124 192 158 174" stroke="#DDF0E3" strokeWidth="14" strokeLinecap="round" opacity="0.75" />
|
||||
<path d="M24 -20L35 282" stroke="#E9E2F0" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M83 -24L73 280" stroke="#DED4E8" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M126 -16L112 268" stroke="#E9E2F0" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M-18 72H158" stroke="#E7DFEE" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M-18 122H158" stroke="#DED5E8" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M-18 212H158" stroke="#E9E2F0" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M-12 244L150 28" stroke="#E6DEED" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M6 180C35 155 48 130 70 117C92 104 108 91 125 70" stroke="#7C3AED" strokeWidth="1.8" strokeDasharray="3 5" strokeLinecap="round" opacity="0.55" />
|
||||
<path d="M70 117C47 103 30 92 14 73" stroke="#006D37" strokeWidth="1.6" strokeDasharray="3 5" strokeLinecap="round" opacity="0.38" />
|
||||
</svg>
|
||||
|
||||
{[
|
||||
["left-[17%] top-[30%]", "bg-primary"],
|
||||
["right-[16%] top-[28%]", "bg-[#006D37]"],
|
||||
["left-[20%] bottom-[33%]", "bg-[#006D37]"],
|
||||
["right-[18%] bottom-[32%]", "bg-primary"],
|
||||
["right-[35%] top-[20%]", "bg-primary"],
|
||||
].map(([position, color]) => (
|
||||
<div key={position} className={`absolute ${position} z-10`}>
|
||||
<div className={`relative h-4 w-4 -rotate-45 rounded-[50%_50%_50%_10%] ${color} shadow-[0_4px_8px_rgba(22,0,29,0.12)]`}>
|
||||
<div className="absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute left-1/2 top-[38%] z-20 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-1/2 top-[82%] h-8 w-14 -translate-x-1/2 rounded-full bg-primary/18 blur-sm" />
|
||||
<div className="relative h-12 w-12 -rotate-45 rounded-[50%_50%_50%_12%] bg-[linear-gradient(135deg,#8B5CF6,#65149A)] shadow-[0_10px_20px_rgba(101,20,154,0.30)]">
|
||||
<div className="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 z-30 rounded-xl bg-white/94 p-2 shadow-[0_8px_20px_rgba(22,0,29,0.12)] backdrop-blur">
|
||||
<p className="truncate text-[8.5px] font-black leading-none text-brand-dark">Your Store</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[6.5px] font-bold text-on-surface-variant">
|
||||
<span className="inline-flex items-center gap-[1.5px]">
|
||||
<MaterialIcon icon="star" size={8} fill className="text-amber-500" />
|
||||
4.8
|
||||
</span>
|
||||
<span className="font-black text-[#006D37]">Open</span>
|
||||
<span>0.8 km</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -left-7 top-[29%] z-40 rounded-full bg-white px-2.5 py-1 text-[8px] font-black text-[#006D37] shadow-[0_8px_16px_rgba(22,0,29,0.12)] ring-1 ring-[#006D37]/10">
|
||||
Offers
|
||||
</div>
|
||||
<div className="absolute -right-6 top-[43%] z-40 rounded-full bg-primary px-2.5 py-1 text-[8px] font-black text-white shadow-[0_8px_16px_rgba(101,20,154,0.20)]">
|
||||
Shop
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepVisual({ type }: { type: string }) {
|
||||
if (type === "store") {
|
||||
return (
|
||||
<div className="absolute inset-x-4 bottom-0 top-2">
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src="/images/nearle-how/how-storefront.webp"
|
||||
alt="3D local shop storefront with purple awning and now online board"
|
||||
fill
|
||||
sizes="(max-width: 768px) 80vw, 28vw"
|
||||
className="object-contain object-bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "phone") {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PhoneMapVisual />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-end justify-end gap-2 px-2">
|
||||
<div className="mb-4 hidden shrink-0 space-y-2 sm:block">
|
||||
{["Offers & Discounts", "Loyal Customers", "Repeat Orders"].map((chip) => (
|
||||
<div key={chip} className="rounded-full border border-primary/10 bg-white px-3 py-2 text-[11px] font-black text-brand-dark shadow-sm">
|
||||
{chip}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative h-40 w-36 shrink-0">
|
||||
<Image
|
||||
src="/images/nearle-how/how-grocery-bag.webp"
|
||||
alt="Nearle grocery bag filled with vegetables and packaged products"
|
||||
fill
|
||||
sizes="(max-width: 768px) 60vw, 18vw"
|
||||
className="object-contain object-bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<>
|
||||
<section className="section bg-white w-full overflow-hidden">
|
||||
{/* HERO AREA */}
|
||||
<div className="mx-auto w-[calc(100%-96px)] max-w-[1720px]">
|
||||
@@ -316,7 +127,7 @@ export function HowItWorks() {
|
||||
<div className="relative w-full max-w-[680px]">
|
||||
<div className="relative h-[280px] overflow-hidden rounded-[2rem] bg-brand-accent-light shadow-[0_26px_70px_rgba(22,0,29,0.16)] md:h-[440px] xl:h-[400px]">
|
||||
<Image
|
||||
src="/images/nearle-merchant/merchant-hero.webp"
|
||||
src="/images/stores/merchant-hero.webp"
|
||||
alt="Indian kirana-store owner using a laptop inside a grocery shop"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
@@ -336,62 +147,32 @@ export function HowItWorks() {
|
||||
<div className="mx-auto mt-10 w-[calc(100%-96px)] max-w-[1720px] md:mt-14 xl:mt-10">
|
||||
<div className="h-px w-full bg-gradient-to-r from-transparent via-primary/10 to-transparent" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CARDS SECTION */}
|
||||
<div className="mx-auto w-full max-w-[2200px] px-6 lg:px-10 xl:px-12">
|
||||
{/* MERCHANT JOURNEY — self-contained cinematic showcase (auto-plays in
|
||||
place; no sticky pin, so no empty scroll-track). */}
|
||||
<section className="bg-white pb-20 pt-12 md:pb-28 md:pt-16">
|
||||
<motion.div
|
||||
className="mt-10 text-center md:mt-14 xl:mt-10"
|
||||
className="mx-auto max-w-3xl px-6 text-center"
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
>
|
||||
<span className="inline-flex rounded-full bg-primary/8 px-4 py-2 text-xs font-black uppercase tracking-widest text-primary">
|
||||
How It Works
|
||||
Merchant Journey
|
||||
</span>
|
||||
<h2 className="mt-5 font-display text-4xl font-black leading-tight tracking-tight text-brand-dark md:text-5xl lg:text-[54px]">
|
||||
Build Your Neighbourhood Brand
|
||||
From your shelf to a growing business
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-on-surface-variant md:text-lg">
|
||||
Three simple milestones to scale your retail reach from offline streets straight into neighbourhood homes.
|
||||
Eight steps, powered by AI. Join once, and Nearle turns your shelf into a
|
||||
discoverable storefront while you stay at the centre of every order.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-10 grid min-w-0 grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
>
|
||||
{STEPS.map((step) => (
|
||||
<motion.article
|
||||
key={step.num}
|
||||
variants={fadeUp}
|
||||
whileHover={{ y: -6, transition: { duration: 0.22, ease: [0.22, 1, 0.36, 1] } }}
|
||||
className="relative flex min-h-[380px] min-w-0 flex-col overflow-hidden rounded-3xl border border-primary/10 bg-white p-7 shadow-[0_18px_45px_rgba(22,0,29,0.08)] hover:shadow-[0_24px_60px_rgba(22,0,29,0.14)] hover:border-primary/20 transition-[box-shadow,border-color] duration-[250ms] md:min-h-[420px] lg:min-h-[460px] xl:min-h-[420px] lg:p-8"
|
||||
>
|
||||
<span className="absolute right-5 top-4 font-display text-6xl font-black leading-none text-primary/[0.07]">
|
||||
{step.num}
|
||||
</span>
|
||||
<div className="relative z-10 flex h-13 w-13 items-center justify-center rounded-2xl bg-primary/7 text-primary">
|
||||
<MaterialIcon icon={step.icon} size={28} />
|
||||
</div>
|
||||
<div className="relative z-10 mt-7 max-w-[22rem] lg:mt-8">
|
||||
<p className="font-display text-2xl font-black text-brand-dark md:text-[28px]">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-on-surface-variant md:text-base">
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`relative z-0 mt-auto w-full overflow-hidden rounded-[1.5rem] bg-brand-accent-light/60 ${step.visual === "phone" ? "h-64 lg:h-72" : "h-48 lg:h-52"}`}>
|
||||
<StepVisual type={step.visual} />
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
<MerchantJourney />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function HyperlocalFeatures() {
|
||||
viewport={viewport}
|
||||
>
|
||||
<Image
|
||||
src="/map1.webp"
|
||||
src="/images/workflow/map1.webp"
|
||||
alt="Map Background"
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@@ -11,7 +11,7 @@ const MotionLink = motion.create(Link);
|
||||
const CATEGORIES = [
|
||||
{
|
||||
name: "Groceries & Daily Essentials",
|
||||
image: "/images/cat-daily-essentials.webp",
|
||||
image: "/images/stores/cat-daily-essentials.webp",
|
||||
tag: "Local Store",
|
||||
tagStyle: "bg-secondary text-white",
|
||||
desc: "Fresh groceries, dairy, and household essentials from nearby shops.",
|
||||
@@ -19,7 +19,7 @@ const CATEGORIES = [
|
||||
},
|
||||
{
|
||||
name: "Wellness",
|
||||
image: "/images/cat-health-wellness.webp",
|
||||
image: "/images/stores/cat-health-wellness.webp",
|
||||
tag: "Available Near You",
|
||||
tagStyle: "bg-primary text-white",
|
||||
desc: "Prescription medicines and wellness supplies from verified pharmacies.",
|
||||
@@ -27,7 +27,7 @@ const CATEGORIES = [
|
||||
},
|
||||
{
|
||||
name: "Food & Snacks",
|
||||
image: "/images/cat-hot-food.webp",
|
||||
image: "/images/stores/cat-hot-food.webp",
|
||||
tag: "Nearby",
|
||||
tagStyle: "bg-brand-accent text-brand-dark",
|
||||
desc: "Fresh meals, bakes, and local snacks from neighbourhood kitchens.",
|
||||
@@ -35,7 +35,7 @@ const CATEGORIES = [
|
||||
},
|
||||
{
|
||||
name: "Fresh Fruits & Vegetables",
|
||||
image: "/vegetable.png",
|
||||
image: "/images/illustrations/vegetable.png",
|
||||
tag: "Local Store",
|
||||
tagStyle: "bg-secondary text-white",
|
||||
desc: "Farm-fresh fruits and vegetables from trusted local vendors.",
|
||||
@@ -43,7 +43,7 @@ const CATEGORIES = [
|
||||
},
|
||||
{
|
||||
name: "Home Needs",
|
||||
image: "/images/cat-pet-essentials.webp",
|
||||
image: "/images/stores/cat-pet-essentials.webp",
|
||||
tag: "Available Near You",
|
||||
tagStyle: "bg-orange-500 text-white",
|
||||
desc: "Kitchen supplies, household cleaners, and daily utilities.",
|
||||
@@ -51,7 +51,7 @@ const CATEGORIES = [
|
||||
},
|
||||
{
|
||||
name: "Local Services",
|
||||
image: "/images/cat-home-services.webp",
|
||||
image: "/images/stores/cat-home-services.webp",
|
||||
tag: "Nearby",
|
||||
tagStyle: "bg-blue-600 text-white",
|
||||
desc: "Verified local handymen, laundry, tailors, and household help.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { siteContent } from "@/config/site-content";
|
||||
import { siteContent } from "@/constants/site-content";
|
||||
import { fadeUp, scaleIn, slideInRight, staggerContainer, viewport } from "@/lib/animations";
|
||||
|
||||
const FLOW_STEPS = [
|
||||
@@ -85,7 +85,7 @@ export function StoreQRSection() {
|
||||
className="h-[60px] flex items-center shrink-0 cursor-pointer select-none transition-all duration-300 hover:scale-[1.03] active:scale-[0.98]"
|
||||
>
|
||||
<Image
|
||||
src="/apple-download.svg"
|
||||
src="/icons/apple-download.svg"
|
||||
alt="Download Nearle on the App Store"
|
||||
width={144}
|
||||
height={60}
|
||||
@@ -102,7 +102,7 @@ export function StoreQRSection() {
|
||||
className="h-[60px] flex items-center shrink-0 cursor-pointer select-none transition-all duration-300 hover:scale-[1.03] active:scale-[0.98]"
|
||||
>
|
||||
<Image
|
||||
src="/play-download.svg"
|
||||
src="/icons/play-download.svg"
|
||||
alt="Download Nearle on Google Play"
|
||||
width={155}
|
||||
height={60}
|
||||
@@ -132,7 +132,7 @@ export function StoreQRSection() {
|
||||
|
||||
<div className="relative w-32 h-32 bg-white flex items-center justify-center">
|
||||
<Image
|
||||
src="/nearle-qr-code.png"
|
||||
src="/images/stores/nearle-qr-code.png"
|
||||
alt="Nearle Store QR Code"
|
||||
width={132}
|
||||
height={132}
|
||||
@@ -153,7 +153,7 @@ export function StoreQRSection() {
|
||||
className="relative w-[290px] shrink-0 aspect-[9/18.5] bg-[#120217] rounded-[2.75rem] border-[7px] border-brand-dark-surface shadow-2xl overflow-hidden ring-4 ring-outline-variant/10"
|
||||
>
|
||||
<Image
|
||||
src="/mobile-image.webp"
|
||||
src="/images/platform/mobile-image.webp"
|
||||
alt="Nearle mobile app interface"
|
||||
width={360}
|
||||
height={760}
|
||||
@@ -199,7 +199,7 @@ export function StoreQRSection() {
|
||||
|
||||
<div className="relative w-36 h-36 bg-white flex items-center justify-center">
|
||||
<Image
|
||||
src="/nearle-qr-code.png"
|
||||
src="/images/stores/nearle-qr-code.png"
|
||||
alt="Nearle Store QR Code"
|
||||
width={144}
|
||||
height={144}
|
||||
@@ -242,7 +242,7 @@ export function StoreQRSection() {
|
||||
viewport={viewport}
|
||||
>
|
||||
<Image
|
||||
src="/mobile-image.webp"
|
||||
src="/images/platform/mobile-image.webp"
|
||||
alt="Nearle mobile app interface"
|
||||
width={360}
|
||||
height={760}
|
||||
@@ -260,7 +260,7 @@ export function StoreQRSection() {
|
||||
className="h-14 flex items-center shrink-0 cursor-pointer select-none transition-all duration-300 hover:scale-[1.03] active:scale-[0.98]"
|
||||
>
|
||||
<Image
|
||||
src="/apple-download.svg"
|
||||
src="/icons/apple-download.svg"
|
||||
alt="Download Nearle on the App Store"
|
||||
width={134}
|
||||
height={56}
|
||||
@@ -276,7 +276,7 @@ export function StoreQRSection() {
|
||||
className="h-14 flex items-center shrink-0 cursor-pointer select-none transition-all duration-300 hover:scale-[1.03] active:scale-[0.98]"
|
||||
>
|
||||
<Image
|
||||
src="/play-download.svg"
|
||||
src="/icons/play-download.svg"
|
||||
alt="Download Nearle on Google Play"
|
||||
width={145}
|
||||
height={56}
|
||||
|
||||
182
src/components/home/VoiceCommerceExperience.tsx
Normal file
182
src/components/home/VoiceCommerceExperience.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { PhoneStage } from "@/components/home/voice/PhoneStage";
|
||||
import { FloatingRequestCards } from "@/components/home/voice/FloatingRequestCards";
|
||||
import { VoiceWaves } from "@/components/home/voice/VoiceWaves";
|
||||
import { viewport } from "@/lib/animations";
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
const EASE_OUT = [0.16, 1, 0.3, 1] as [number, number, number, number];
|
||||
|
||||
const FEATURE_CHIPS = [
|
||||
{ emoji: "🎤", label: "Voice Shopping" },
|
||||
{ emoji: "🧠", label: "Natural Language AI" },
|
||||
{ emoji: "🏪", label: "Local Merchant Discovery" },
|
||||
{ emoji: "⚡", label: "Real-Time Availability" },
|
||||
];
|
||||
|
||||
const badgeVariant = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: EASE } },
|
||||
};
|
||||
|
||||
const lineStagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.14, delayChildren: 0.12 } },
|
||||
};
|
||||
|
||||
const lineReveal = {
|
||||
hidden: { y: 48, opacity: 0, filter: "blur(8px)" },
|
||||
visible: { y: 0, opacity: 1, filter: "blur(0px)", transition: { duration: 0.85, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
const descVariant = {
|
||||
hidden: { opacity: 0, y: 22 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, delay: 0.42, ease: EASE } },
|
||||
};
|
||||
|
||||
const chipStagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.07, delayChildren: 0.56 } },
|
||||
};
|
||||
|
||||
const chipItem = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE } },
|
||||
};
|
||||
|
||||
const ctaVariant = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, delay: 0.86, ease: EASE } },
|
||||
};
|
||||
|
||||
const phoneVariant = {
|
||||
hidden: { opacity: 0, y: 40, scale: 0.96 },
|
||||
visible: { opacity: 1, y: 0, scale: 1, transition: { duration: 1.05, delay: 0.15, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
// The homepage's product showcase: a live Nearle app running inside the phone,
|
||||
// so a visitor can watch a spoken request turn into a delivered order without
|
||||
// installing anything. The Hero above introduces Nearle — this shows the work.
|
||||
export function VoiceCommerceExperience() {
|
||||
return (
|
||||
// Explicit padding rather than the shared .section rhythm: the cards that
|
||||
// float above and below the phone need more room than 88px to breathe.
|
||||
<section
|
||||
className="relative w-full overflow-hidden py-24 lg:py-32"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(60% 50% at 78% 32%, rgba(124,58,237,0.13), transparent 70%)," +
|
||||
"radial-gradient(45% 42% at 12% 22%, rgba(168,85,247,0.10), transparent 70%)," +
|
||||
"linear-gradient(180deg, #FDFBFF 0%, #F7F3FF 55%, #FAF7FF 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Voice waves sit behind everything, centred on the phone side */}
|
||||
<VoiceWaves className="left-auto right-0 z-0 hidden w-[62%] lg:block" />
|
||||
<VoiceWaves className="z-0 lg:hidden" />
|
||||
|
||||
<div className="page-container relative z-10">
|
||||
<div className="grid items-center gap-16 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-8 xl:gap-12">
|
||||
{/* ─── LEFT: what it does ─── */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="order-1 flex flex-col items-center text-center lg:items-start lg:text-left"
|
||||
>
|
||||
<motion.span
|
||||
variants={badgeVariant}
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-full border border-[#7C3AED]/15 bg-white px-4 py-1.5 text-xs font-black uppercase tracking-widest text-[#6D28D9] shadow-[0_4px_18px_-6px_rgba(124,58,237,0.35)]"
|
||||
>
|
||||
<MaterialIcon icon="graphic_eq" size={14} />
|
||||
AI Voice Commerce
|
||||
</motion.span>
|
||||
|
||||
<motion.h2
|
||||
variants={lineStagger}
|
||||
className="mt-6 font-display text-[32px] font-extrabold tracking-[-0.05em] text-[#16001D] sm:text-[42px] lg:text-[42px] xl:text-[52px]"
|
||||
>
|
||||
<div className="overflow-hidden pb-1">
|
||||
<motion.div variants={lineReveal} className="leading-[0.98]">
|
||||
Just speak.
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="overflow-hidden pb-1">
|
||||
{/* text-balance keeps the mobile wrap as "Nearle handles /
|
||||
the rest." instead of orphaning "rest." on its own line */}
|
||||
<motion.div variants={lineReveal} className="text-balance leading-[0.98]">
|
||||
Nearle handles <span className="text-[#7C3AED]">the rest.</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={descVariant}
|
||||
className="mt-5 max-w-[520px] text-base font-medium leading-[1.6] text-[#4A3F52] md:text-lg"
|
||||
>
|
||||
Order products, discover nearby stores, and book trusted local services using
|
||||
natural conversation.
|
||||
</motion.p>
|
||||
|
||||
<motion.ul
|
||||
variants={chipStagger}
|
||||
className="mt-7 flex flex-wrap justify-center gap-2.5 lg:justify-start"
|
||||
>
|
||||
{FEATURE_CHIPS.map((chip) => (
|
||||
<motion.li
|
||||
key={chip.label}
|
||||
variants={chipItem}
|
||||
className="flex items-center gap-2 rounded-full border border-[#7C3AED]/12 bg-white/70 py-2 pl-2.5 pr-4 shadow-[0_6px_20px_-10px_rgba(76,20,140,0.35)] backdrop-blur-md"
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#7C3AED]/[0.08] text-[13px]">
|
||||
{chip.emoji}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-[#2C1A38]">{chip.label}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
|
||||
<motion.div variants={ctaVariant} className="mt-8">
|
||||
<Link
|
||||
href="/platform"
|
||||
className="group inline-flex items-center gap-2.5 rounded-full bg-[#6330D6] px-8 py-4 text-sm font-black uppercase tracking-wider text-white shadow-[0_14px_38px_-12px_rgba(99,48,214,0.75)] transition-all duration-200 hover:-translate-y-0.5 hover:bg-[#5225b8] hover:shadow-[0_18px_44px_-12px_rgba(99,48,214,0.85)] active:translate-y-0 active:scale-[0.98]"
|
||||
>
|
||||
<MaterialIcon icon="mic" size={18} />
|
||||
Try Voice Commerce
|
||||
<MaterialIcon
|
||||
icon="arrow_forward"
|
||||
size={17}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── RIGHT: the live phone ─── */}
|
||||
<motion.div
|
||||
variants={phoneVariant}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="relative order-2 flex justify-center lg:justify-end"
|
||||
>
|
||||
{/* Width-driven at every breakpoint — the 476/941 aspect ratio
|
||||
derives the height, so the phone never collapses. */}
|
||||
<div className="relative w-[min(72vw,300px)] lg:w-[min(38vh,400px)]">
|
||||
{/* soft purple glow anchoring the device */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[86%] w-[150%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#A855F7]/16 blur-[80px]"
|
||||
/>
|
||||
<PhoneStage className="w-full" />
|
||||
<FloatingRequestCards />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { fadeUp, fadeIn, staggerContainer, viewport } from "@/lib/animations";
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
title: "Neighbourhood Trust",
|
||||
desc: "Every order is fulfilled by a real local shop your street already knows — not an anonymous warehouse.",
|
||||
icon: "handshake",
|
||||
title: "Understands your intent",
|
||||
desc: "Reads natural language and knows what you actually need.",
|
||||
icon: "psychology",
|
||||
color: "bg-primary/5 text-primary border-primary/10",
|
||||
},
|
||||
{
|
||||
title: "AI-Powered Catalog",
|
||||
desc: "ProductLLM turns a shelf into a live, shoppable catalog in minutes — so any store can go online without the busywork.",
|
||||
icon: "auto_awesome",
|
||||
title: "Finds matching products",
|
||||
desc: "Maps your intent to real items on your store’s shelves.",
|
||||
icon: "inventory_2",
|
||||
color: "bg-secondary/5 text-secondary border-secondary/10",
|
||||
},
|
||||
{
|
||||
title: "Offline-First Commerce",
|
||||
desc: "Billing and inventory keep working even when the network drops. Built for how neighbourhood stores really run.",
|
||||
icon: "wifi_off",
|
||||
title: "Recommends alternatives",
|
||||
desc: "Out of stock? It suggests the closest match in the store.",
|
||||
icon: "autorenew",
|
||||
color: "bg-[#de96f9]/10 text-primary border-[#de96f9]/20",
|
||||
},
|
||||
{
|
||||
title: "Checks availability",
|
||||
desc: "Confirms what’s in stock before you place the order.",
|
||||
icon: "storefront",
|
||||
color: "bg-secondary/5 text-secondary border-secondary/10",
|
||||
},
|
||||
{
|
||||
title: "Helps you shop faster",
|
||||
desc: "Fewer taps — everyday essentials in just seconds.",
|
||||
icon: "bolt",
|
||||
color: "bg-primary/5 text-primary border-primary/10",
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyAICommerceWins() {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section className="section bg-white w-full overflow-hidden">
|
||||
<div className="page-container relative">
|
||||
@@ -38,39 +52,43 @@ export function WhyAICommerceWins() {
|
||||
viewport={viewport}
|
||||
>
|
||||
<span className="text-xs text-primary uppercase tracking-widest font-black bg-primary/5 px-3.5 py-1.5 rounded-full">
|
||||
AI Commerce
|
||||
The Assistant
|
||||
</span>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-[54px] font-display font-black text-brand-dark mt-3 leading-tight tracking-tight">
|
||||
Why AI Commerce <span className="text-primary font-normal">Wins</span>
|
||||
What Nearle AI <span className="text-primary font-normal">Does</span>
|
||||
</h2>
|
||||
<p className="text-body-lg text-on-surface-variant mt-2.5 max-w-xl mx-auto leading-relaxed">
|
||||
Nearle isn’t another delivery app. It’s the AI layer that brings
|
||||
neighbourhood stores online — without replacing them.
|
||||
The store stays the hero. Nearle AI is the quiet assistant that helps you
|
||||
find what you need inside it — faster.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-5 lg:gap-7"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-5"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
>
|
||||
{CARDS.map((card) => (
|
||||
{CARDS.map((card, i) => (
|
||||
<motion.div
|
||||
key={card.title}
|
||||
variants={fadeIn}
|
||||
className="premium-card bg-white rounded-3xl p-6 md:p-7 border border-outline-variant/15 shadow-lg flex flex-col h-full"
|
||||
className="premium-card bg-white rounded-3xl p-5 md:p-6 border border-outline-variant/15 shadow-lg flex flex-col h-full"
|
||||
whileHover={{ y: -4, scale: 1.02, transition: { duration: 0.22, ease: [0.22, 1, 0.36, 1] } }}
|
||||
whileTap={{ scale: 0.98, transition: { duration: 0.1 } }}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center border mb-5 shrink-0 ${card.color}`}>
|
||||
<MaterialIcon icon={card.icon} size={27} />
|
||||
</div>
|
||||
<p className="font-display text-xl font-black text-brand-dark mb-3">
|
||||
<motion.div
|
||||
className={`w-[52px] h-[52px] rounded-2xl flex items-center justify-center border mb-4 shrink-0 ${card.color}`}
|
||||
animate={reduce ? {} : { scale: [1, 1.08, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 2.8, ease: "easeInOut", delay: i * 0.25 }}
|
||||
>
|
||||
<MaterialIcon icon={card.icon} size={25} />
|
||||
</motion.div>
|
||||
<p className="font-display text-lg font-black text-brand-dark mb-2 leading-tight">
|
||||
{card.title}
|
||||
</p>
|
||||
<p className="text-sm md:text-base text-on-surface-variant leading-relaxed">
|
||||
<p className="text-sm text-on-surface-variant leading-relaxed">
|
||||
{card.desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -7,36 +7,28 @@ import { fadeUp, fadeIn, staggerContainer, viewport } from "@/lib/animations";
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
title: "Shops You Know",
|
||||
desc: "Order from familiar neighbourhood stores near you.",
|
||||
icon: "store",
|
||||
title: "Shop Your Trusted Store",
|
||||
desc: "Browse products from neighbourhood stores you already know and trust.",
|
||||
icon: "storefront",
|
||||
color: "bg-primary/5 text-primary border-primary/10",
|
||||
image: "/images/nearle-redesign/shops-you-know.webp",
|
||||
image: "/images/illustrations/shops-you-know.webp",
|
||||
alt: "Friendly local grocery shop owner inside a kirana store",
|
||||
},
|
||||
{
|
||||
title: "Better Local Value",
|
||||
desc: "Discover local offers and daily savings nearby.",
|
||||
icon: "local_offer",
|
||||
color: "bg-secondary/5 text-secondary border-secondary/10",
|
||||
image: "/images/nearle-redesign/better-local-value.webp",
|
||||
alt: "Fresh local vegetables and fruits with a local offers sign",
|
||||
},
|
||||
{
|
||||
title: "Easy Reordering",
|
||||
desc: "Save your favourite stores for quick reordering.",
|
||||
icon: "history",
|
||||
title: "AI Assisted Shopping",
|
||||
desc: "Search naturally. Nearle AI understands what you’re looking for and recommends relevant products.",
|
||||
icon: "auto_awesome",
|
||||
color: "bg-[#de96f9]/10 text-primary border-[#de96f9]/20",
|
||||
image: "/images/nearle-redesign/easy-reordering.webp",
|
||||
image: "/images/illustrations/easy-reordering.webp",
|
||||
alt: "Hand holding a smartphone with a simple grocery ordering app",
|
||||
},
|
||||
{
|
||||
title: "Support Local Business",
|
||||
desc: "Every order directly supports nearby shopkeepers.",
|
||||
icon: "favorite",
|
||||
color: "bg-rose-500/5 text-rose-700 border-rose-100",
|
||||
image: "/images/nearle-redesign/support-local-business.webp",
|
||||
alt: "Local shop owner and customer shaking hands inside a store",
|
||||
title: "Local Delivery",
|
||||
desc: "Orders go directly to the merchant and are delivered by Nearle riders.",
|
||||
icon: "two_wheeler",
|
||||
color: "bg-secondary/5 text-secondary border-secondary/10",
|
||||
image: "/images/illustrations/support-local-business.webp",
|
||||
alt: "Local shop owner handing a packed order to a delivery rider",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,7 +46,7 @@ export function WhyNearle() {
|
||||
>
|
||||
<div className="pointer-events-none absolute -left-2 top-0 hidden h-36 w-36 overflow-hidden rounded-[2rem] bg-primary/5 shadow-[0_18px_45px_rgba(101,20,154,0.12)] md:block lg:-left-4 lg:h-44 lg:w-44">
|
||||
<Image
|
||||
src="/images/nearle-redesign/heading-shopper.webp"
|
||||
src="/images/illustrations/heading-shopper.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="176px"
|
||||
@@ -63,7 +55,7 @@ export function WhyNearle() {
|
||||
</div>
|
||||
<div className="pointer-events-none absolute -right-2 top-1 hidden h-32 w-40 overflow-hidden rounded-[2rem] bg-secondary/5 shadow-[0_18px_45px_rgba(22,0,29,0.10)] md:block lg:-right-4 lg:h-40 lg:w-52">
|
||||
<Image
|
||||
src="/images/nearle-redesign/heading-store.webp"
|
||||
src="/images/illustrations/heading-store.webp"
|
||||
alt=""
|
||||
fill
|
||||
sizes="208px"
|
||||
@@ -74,16 +66,17 @@ export function WhyNearle() {
|
||||
Our Philosophy
|
||||
</span>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-[54px] font-display font-black text-brand-dark mt-3 leading-tight tracking-tight">
|
||||
Why Nearle <span className="text-primary font-normal">Feels Different</span>
|
||||
Why <span className="text-primary font-normal">Nearle</span>
|
||||
</h2>
|
||||
<p className="text-body-lg text-on-surface-variant mt-2.5 max-w-xl mx-auto leading-relaxed">
|
||||
Nearle brings the convenience of an app to the stores you already know and trust.
|
||||
The store you trust, the assistant that helps you shop, and delivery that keeps
|
||||
the merchant at the centre.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 lg:gap-7"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-7"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
|
||||
84
src/components/home/experience/AIThinking.tsx
Normal file
84
src/components/home/experience/AIThinking.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type AIThinkingProps = {
|
||||
steps: readonly string[];
|
||||
activeStep: number;
|
||||
/** Tightens spacing/size for the compact device preview. */
|
||||
compact?: boolean;
|
||||
/** "dark" inverts the text for the hero's dark phone screen. */
|
||||
tone?: "light" | "dark";
|
||||
};
|
||||
|
||||
// The staged "AI is working" beats: each line lights up in sequence.
|
||||
export function AIThinking({ steps, activeStep, compact = false, tone = "light" }: AIThinkingProps) {
|
||||
const dark = tone === "dark";
|
||||
return (
|
||||
<div className={compact ? "space-y-1.5" : "space-y-2.5"}>
|
||||
{steps.map((label, i) => {
|
||||
const done = i < activeStep;
|
||||
const active = i === activeStep;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`flex items-center gap-2.5 transition-opacity duration-300 ${
|
||||
i <= activeStep ? "opacity-100" : "opacity-35"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`relative flex shrink-0 items-center justify-center rounded-full ${
|
||||
compact ? "h-4 w-4" : "h-5 w-5"
|
||||
} ${
|
||||
done
|
||||
? dark
|
||||
? "bg-emerald-400/20 text-emerald-300"
|
||||
: "bg-secondary/15 text-secondary"
|
||||
: dark
|
||||
? "bg-[#A855F7]/20 text-[#C9A2FF]"
|
||||
: "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
{done ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={compact ? "h-2.5 w-2.5" : "h-3 w-3"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : active ? (
|
||||
<motion.span
|
||||
className={`rounded-full ${dark ? "bg-[#C9A2FF]" : "bg-primary"} ${compact ? "h-1.5 w-1.5" : "h-2 w-2"}`}
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [1, 0.5, 1] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`rounded-full ${dark ? "bg-[#C9A2FF]/40" : "bg-primary/40"} ${compact ? "h-1.5 w-1.5" : "h-2 w-2"}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`font-bold ${compact ? "text-[10px]" : "text-sm"} ${
|
||||
dark
|
||||
? done || active
|
||||
? "text-white"
|
||||
: "text-white/45"
|
||||
: done || active
|
||||
? "text-brand-dark"
|
||||
: "text-on-surface-variant"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
src/components/home/experience/StoreCanvas.tsx
Normal file
314
src/components/home/experience/StoreCanvas.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { AIThinking } from "./AIThinking";
|
||||
import {
|
||||
AI_STEPS,
|
||||
FULFILMENT_STEPS,
|
||||
type JourneyStage,
|
||||
type StoreJourney,
|
||||
} from "@/hooks/useStoreJourney";
|
||||
|
||||
const screenEase = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
// Slow, smooth screen changes (Apple/Linear-style) instead of snappy cuts.
|
||||
const screenMotion = {
|
||||
initial: { opacity: 0, y: 14, scale: 0.985 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
exit: { opacity: 0, y: -12, scale: 0.99 },
|
||||
transition: { duration: 0.65, ease: screenEase },
|
||||
};
|
||||
|
||||
// Which visual screen a stage maps to (enter-store + search share one screen so
|
||||
// the transition into typing stays seamless; fulfilment + done share the flow).
|
||||
function screenKey(stage: JourneyStage): string {
|
||||
if (stage === "enter-store" || stage === "search") return "storefront";
|
||||
if (stage === "done") return "fulfilment";
|
||||
return stage;
|
||||
}
|
||||
|
||||
// The device frame whose inner screen swaps as the journey advances.
|
||||
export function StoreCanvas({ journey }: { journey: StoreJourney }) {
|
||||
const {
|
||||
stage,
|
||||
stores,
|
||||
store,
|
||||
intent,
|
||||
recommendation,
|
||||
typed,
|
||||
thinkingStep,
|
||||
fulfilStep,
|
||||
reduced,
|
||||
selectStore,
|
||||
} = journey;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full max-w-[320px] items-center justify-center">
|
||||
{/* Ambient brand glow */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 scale-110 rounded-full bg-gradient-to-tr from-primary/20 to-[#a855f7]/20 blur-3xl"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
animate={reduced ? undefined : { y: [-6, 6, -6] }}
|
||||
transition={{ repeat: Infinity, duration: 6, ease: "easeInOut" }}
|
||||
className="relative z-10 aspect-[9/18.6] w-full select-none overflow-hidden rounded-[42px] border-[11px] border-brand-dark bg-brand-dark shadow-[0_28px_60px_rgba(22,0,29,0.22)]"
|
||||
>
|
||||
{/* Dynamic island */}
|
||||
<div className="absolute left-1/2 top-3 z-30 flex h-5 w-24 -translate-x-1/2 items-center justify-end rounded-full bg-brand-dark px-3">
|
||||
<span className="mr-0.5 h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="h-1 w-1 rounded-full bg-blue-500/60" />
|
||||
</div>
|
||||
|
||||
{/* Screen */}
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-[30px] bg-[#FBF8FE] text-brand-dark">
|
||||
<AnimatePresence mode="wait">
|
||||
{screenKey(stage) === "choose-store" && (
|
||||
<motion.div key="choose-store" {...screenMotion} className="flex h-full flex-col">
|
||||
<div className="bg-primary px-4 pb-4 pt-8 text-white">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold opacity-90">
|
||||
<MaterialIcon icon="location_on" size={12} className="shrink-0 text-white" />
|
||||
<span className="truncate">R.S. Puram, Coimbatore</span>
|
||||
</div>
|
||||
<p className="mt-2 font-display text-base font-black tracking-tight">My Stores</p>
|
||||
<p className="text-[10px] font-semibold opacity-80">Trusted shops you’ve added</p>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden px-3.5 pt-3.5">
|
||||
{stores.map((s) => {
|
||||
const active = s.id === store.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => selectStore(s.id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-2xl border bg-white px-3 py-2.5 text-left transition-all ${
|
||||
active
|
||||
? "border-primary/40 shadow-[0_0_0_3px_rgba(101,20,154,0.10)]"
|
||||
: "border-primary/10"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<MaterialIcon icon={s.icon} size={16} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-black text-brand-dark">
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="block truncate text-[9px] font-semibold text-on-surface-variant">
|
||||
{s.area}
|
||||
</span>
|
||||
</span>
|
||||
<MaterialIcon
|
||||
icon={active ? "check_circle" : "chevron_right"}
|
||||
size={16}
|
||||
className={active ? "text-primary" : "text-on-surface-variant/50"}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{screenKey(stage) === "storefront" && (
|
||||
<motion.div key="storefront" {...screenMotion} className="flex h-full flex-col">
|
||||
<StoreHeader name={store.name} area={store.area} />
|
||||
{/* In-store search */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 rounded-full border border-primary/15 bg-white px-3.5 py-2 shadow-sm">
|
||||
<MaterialIcon icon="search" size={14} className="shrink-0 text-primary/60" />
|
||||
{typed ? (
|
||||
<span className="truncate text-[11px] font-semibold text-brand-dark">{typed}</span>
|
||||
) : (
|
||||
<span className="truncate text-[11px] text-on-surface-variant/70">
|
||||
Search inside this store…
|
||||
</span>
|
||||
)}
|
||||
{stage === "search" && !reduced && (
|
||||
<motion.span
|
||||
className="ml-0.5 h-3 w-[1.5px] bg-primary"
|
||||
animate={{ opacity: [1, 0, 1] }}
|
||||
transition={{ duration: 0.9, repeat: Infinity }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Categories */}
|
||||
<div className="px-4">
|
||||
<p className="mb-2 text-[9px] font-black uppercase tracking-widest text-on-surface-variant/70">
|
||||
Shop by aisle
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STORE_CATEGORIES.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex flex-col items-center gap-1 rounded-2xl border border-primary/8 bg-white px-1 py-2.5"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-xl bg-primary/8 text-primary">
|
||||
<MaterialIcon icon={c.icon} size={15} />
|
||||
</span>
|
||||
<span className="text-[8px] font-bold text-brand-dark">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{screenKey(stage) === "thinking" && (
|
||||
<motion.div key="thinking" {...screenMotion} className="flex h-full flex-col">
|
||||
<StoreHeader name={store.name} area={store.area} />
|
||||
<div className="flex flex-1 flex-col justify-center px-4">
|
||||
<div className="rounded-2xl border border-primary/10 bg-white px-3.5 py-4 shadow-sm">
|
||||
<p className="mb-2.5 flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-primary">
|
||||
<MaterialIcon icon="auto_awesome" size={11} />
|
||||
Nearle AI
|
||||
</p>
|
||||
<AIThinking steps={AI_STEPS} activeStep={thinkingStep} compact />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{screenKey(stage) === "results" && recommendation && (
|
||||
<motion.div key="results" {...screenMotion} className="flex h-full flex-col">
|
||||
<StoreHeader name={store.name} area={store.area} />
|
||||
<div className="flex-1 overflow-hidden px-4 pt-3">
|
||||
<p className="mb-2 flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-primary">
|
||||
<MaterialIcon icon="auto_awesome" size={11} />
|
||||
Recommended · {recommendation.intent.bundleName}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{recommendation.products.map((p, i) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={reduced ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: reduced ? 0 : 0.15 + i * 0.22, duration: 0.5, ease: screenEase }}
|
||||
className="flex items-center gap-2.5 rounded-xl border border-primary/8 bg-white px-2.5 py-2 shadow-sm"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/[0.06] text-base">
|
||||
{p.emoji}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-black text-brand-dark">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] font-black text-primary">₹{p.price}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center gap-1.5 rounded-xl bg-primary/[0.06] px-2.5 py-2 text-[9px] font-bold text-primary">
|
||||
<MaterialIcon icon="storefront" size={12} />
|
||||
<span className="truncate">Available in {store.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{screenKey(stage) === "cart" && recommendation && (
|
||||
<motion.div key="cart" {...screenMotion} className="flex h-full flex-col">
|
||||
<StoreHeader name={store.name} area={store.area} />
|
||||
<div className="flex flex-1 flex-col justify-center px-4">
|
||||
<div className="rounded-2xl border border-primary/10 bg-white p-4 shadow-sm">
|
||||
<p className="font-display text-sm font-black text-brand-dark">
|
||||
{recommendation.intent.bundleName}
|
||||
</p>
|
||||
<p className="text-[10px] font-semibold text-on-surface-variant">
|
||||
{recommendation.itemCount} items · {store.name}
|
||||
</p>
|
||||
<div className="my-3 h-px w-full bg-primary/8" />
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-[10px] font-bold text-on-surface-variant">Total</span>
|
||||
<span className="font-display text-xl font-black text-brand-dark">
|
||||
₹{recommendation.total}
|
||||
</span>
|
||||
</div>
|
||||
<motion.div
|
||||
whileTap={reduced ? undefined : { scale: 0.97 }}
|
||||
className="mt-3 flex items-center justify-center gap-1.5 rounded-full bg-primary py-2.5 text-[11px] font-black text-white"
|
||||
>
|
||||
<MaterialIcon icon="bolt" size={13} />
|
||||
Place Order
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{screenKey(stage) === "fulfilment" && (
|
||||
<motion.div key="fulfilment" {...screenMotion} className="flex h-full flex-col">
|
||||
<StoreHeader name={store.name} area={store.area} />
|
||||
<div className="flex flex-1 flex-col justify-center px-4">
|
||||
<p className="mb-3 flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-secondary">
|
||||
<MaterialIcon icon="local_shipping" size={12} />
|
||||
Order on the way
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{FULFILMENT_STEPS.map((step, i) => {
|
||||
const done = i < fulfilStep;
|
||||
const active = i === fulfilStep;
|
||||
return (
|
||||
<div
|
||||
key={step.label}
|
||||
className={`flex items-center gap-2.5 transition-opacity duration-300 ${
|
||||
i <= fulfilStep ? "opacity-100" : "opacity-35"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
|
||||
done || active
|
||||
? "bg-secondary/15 text-secondary"
|
||||
: "bg-on-surface-variant/10 text-on-surface-variant"
|
||||
}`}
|
||||
>
|
||||
{done ? (
|
||||
<MaterialIcon icon="check" size={12} />
|
||||
) : active && !reduced ? (
|
||||
<motion.span
|
||||
className="h-2 w-2 rounded-full bg-secondary"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [1, 0.5, 1] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon icon={step.icon} size={12} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-brand-dark">{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STORE_CATEGORIES = [
|
||||
{ name: "Groceries", icon: "shopping_basket" },
|
||||
{ name: "Dairy", icon: "egg" },
|
||||
{ name: "Snacks", icon: "cookie" },
|
||||
{ name: "Household", icon: "cleaning_services" },
|
||||
{ name: "Personal", icon: "self_care" },
|
||||
{ name: "More", icon: "grid_view" },
|
||||
];
|
||||
|
||||
function StoreHeader({ name, area }: { name: string; area: string }) {
|
||||
return (
|
||||
<div className="bg-primary px-4 pb-3 pt-8 text-white">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold opacity-90">
|
||||
<MaterialIcon icon="storefront" size={12} className="shrink-0 text-white" />
|
||||
<span className="truncate">{area}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between">
|
||||
<span className="truncate font-display text-base font-black tracking-tight">{name}</span>
|
||||
<MaterialIcon icon="shopping_cart" size={15} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
src/components/home/experience/StoreJourney.tsx
Normal file
212
src/components/home/experience/StoreJourney.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { fadeUp, staggerContainer, viewport } from "@/lib/animations";
|
||||
import { StoreCanvas } from "./StoreCanvas";
|
||||
import {
|
||||
type JourneyStage,
|
||||
STAGE_ORDER,
|
||||
useStoreJourney,
|
||||
} from "@/hooks/useStoreJourney";
|
||||
|
||||
// The seven progress beats (fulfilment + done collapse into one visible step).
|
||||
const PROGRESS = STAGE_ORDER.filter((s) => s !== "done");
|
||||
|
||||
const CAPTIONS: Record<JourneyStage, { title: string; body: string }> = {
|
||||
"choose-store": {
|
||||
title: "Choose your store",
|
||||
body: "Start with a neighbourhood shop you already know and trust — added once with a quick QR scan.",
|
||||
},
|
||||
"enter-store": {
|
||||
title: "Step inside",
|
||||
body: "Walk into your store digitally. The same shelves and the same shopkeeper — now searchable.",
|
||||
},
|
||||
search: {
|
||||
title: "Say what you need",
|
||||
body: "Search in plain language. No need to know exact brands or product names.",
|
||||
},
|
||||
thinking: {
|
||||
title: "Nearle AI understands",
|
||||
body: "AI reads your intent, finds matching products, and checks what’s actually in stock.",
|
||||
},
|
||||
results: {
|
||||
title: "Picks from your store",
|
||||
body: "Every recommendation comes from the store you chose — never an anonymous warehouse.",
|
||||
},
|
||||
cart: {
|
||||
title: "One tap to order",
|
||||
body: "Build your basket and send it straight to the merchant — no middleman.",
|
||||
},
|
||||
fulfilment: {
|
||||
title: "Merchant packs, Nearle delivers",
|
||||
body: "Your shopkeeper packs the order and a Nearle rider brings it to your door.",
|
||||
},
|
||||
done: {
|
||||
title: "Delivered by your neighbourhood",
|
||||
body: "Your shopkeeper packed the order and a Nearle rider brought it home.",
|
||||
},
|
||||
};
|
||||
|
||||
export function StoreJourney() {
|
||||
const journey = useStoreJourney();
|
||||
const { stage, intent, intents, pickIntent, replay } = journey;
|
||||
|
||||
const caption = CAPTIONS[stage];
|
||||
const activeIndex = stage === "done" ? PROGRESS.length - 1 : PROGRESS.indexOf(stage);
|
||||
|
||||
return (
|
||||
<section className="section w-full overflow-hidden bg-white">
|
||||
<div className="page-container">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
onViewportEnter={() => journey.start()}
|
||||
className="mx-auto max-w-[760px] text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary/5 px-3.5 py-1.5 text-xs font-black uppercase tracking-widest text-primary"
|
||||
>
|
||||
<MaterialIcon icon="auto_awesome" size={14} />
|
||||
Live Demo
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="mt-3 font-display text-4xl font-black leading-tight tracking-tight text-brand-dark md:text-5xl lg:text-[54px]"
|
||||
>
|
||||
Your Neighbourhood Store, <span className="text-primary font-normal">Now Digital</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-3 max-w-xl text-body-lg leading-relaxed text-on-surface-variant"
|
||||
>
|
||||
Browse products from your trusted nearby stores anytime. Nearle AI helps you discover
|
||||
products faster — without changing how your favourite store works.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Interactive journey */}
|
||||
<div className="mt-12 grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
||||
{/* LEFT: caption + progress + intent chips */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="order-2 lg:order-1"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="mb-6 flex items-center gap-1.5" aria-hidden="true">
|
||||
{PROGRESS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors duration-300 ${
|
||||
i <= activeIndex ? "bg-primary" : "bg-primary/12"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stage caption */}
|
||||
<div className="min-h-[120px]">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-primary/70">
|
||||
Step {activeIndex + 1} of {PROGRESS.length}
|
||||
</p>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={stage === "done" ? "fulfilment-caption" : stage}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<h3 className="mt-2 font-display text-2xl font-black tracking-tight text-brand-dark md:text-[28px]">
|
||||
{caption.title}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-base leading-relaxed text-on-surface-variant">
|
||||
{caption.body}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Intent chips — drive the demo */}
|
||||
<div className="mt-7">
|
||||
<p className="mb-2.5 flex items-center gap-1.5 text-xs font-black uppercase tracking-widest text-on-surface-variant/70">
|
||||
<MaterialIcon icon="bolt" size={14} />
|
||||
Try a shopping intent
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{intents.map((it) => {
|
||||
const active = it.id === intent.id;
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => pickIntent(it.id)}
|
||||
className={`rounded-full border px-3.5 py-1.5 text-[13px] font-bold transition-colors duration-200 ${
|
||||
active
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-primary/15 bg-primary/[0.04] text-primary hover:border-primary/30 hover:bg-primary/[0.08]"
|
||||
}`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replay appears once the story completes */}
|
||||
<div className="mt-6 h-9">
|
||||
<AnimatePresence>
|
||||
{stage === "done" && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
type="button"
|
||||
onClick={replay}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-white px-4 py-2 text-sm font-black text-primary transition-colors hover:bg-primary/[0.06]"
|
||||
>
|
||||
<MaterialIcon icon="replay" size={18} />
|
||||
Watch again
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT: the store canvas */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="order-1 lg:order-2"
|
||||
>
|
||||
<StoreCanvas journey={journey} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Closing product message */}
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mx-auto mt-14 max-w-2xl text-center font-display text-lg font-black leading-relaxed text-brand-dark md:text-xl"
|
||||
>
|
||||
Your trusted neighbourhood store, now in your pocket.{" "}
|
||||
<span className="text-primary font-normal">
|
||||
Powered by AI, fulfilled by local merchants, delivered by Nearle.
|
||||
</span>
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
175
src/components/home/merchant/MerchantJourney.tsx
Normal file
175
src/components/home/merchant/MerchantJourney.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { useMerchantJourney, type MerchantStep } from "@/hooks/useMerchantJourney";
|
||||
import { MERCHANT_SCENES } from "./illustrations";
|
||||
import { MerchantPhone } from "./MerchantPhone";
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
|
||||
// The flagship merchant story as a self-contained cinematic showcase — NO sticky
|
||||
// pin, no tall scroll-track, so there is no empty space to scroll through. The
|
||||
// moment it enters view it auto-plays through the eight beats: the illustration
|
||||
// cross-dissolves (each scene re-mounts, so its entrance animation replays), the
|
||||
// live phone updates, the caption changes and the progress fills. Every chapter
|
||||
// node is clickable to jump; reduced-motion users drive it manually.
|
||||
export function MerchantJourney() {
|
||||
const journey = useMerchantJourney();
|
||||
const { steps, index, step, reduced, atEnd, start, goTo, next, prev, replay } = journey;
|
||||
const N = steps.length;
|
||||
const fillPct = (index / (N - 1)) * 100;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto mt-10 flex max-w-5xl flex-col items-center px-6"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.6, ease: EASE }}
|
||||
onViewportEnter={() => start()}
|
||||
>
|
||||
{/* Caption — compact, centred */}
|
||||
<div className="text-center" aria-live="polite" aria-atomic="true">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.2em] text-on-surface-variant/60">
|
||||
Chapter {index + 1} <span className="text-primary/40">/ {N}</span>
|
||||
</span>
|
||||
<div className="mt-2 min-h-[132px] md:min-h-[120px]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={reduced ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.35, ease: EASE }}
|
||||
>
|
||||
<h3 className="font-display text-3xl font-black leading-[1.05] tracking-tight text-brand-dark md:text-[40px]">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="mx-auto mt-3 max-w-lg text-base leading-relaxed text-on-surface-variant md:text-lg">
|
||||
{step.desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero stage — illustration + live phone, centred and dominant */}
|
||||
<div className="mt-6 flex w-full items-center justify-center gap-6 lg:gap-10">
|
||||
<div className="w-[clamp(280px,44vw,480px)]">
|
||||
<Canvas step={step} reduced={reduced} />
|
||||
</div>
|
||||
<div className="hidden w-[150px] shrink-0 md:block lg:w-[170px]">
|
||||
<MerchantPhone step={step} reduced={reduced} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter rail — filling line, active beat highlighted, clickable */}
|
||||
<div className="mt-9 w-full max-w-2xl">
|
||||
<div className="relative h-[3px] rounded-full bg-primary/12">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary"
|
||||
animate={{ width: `${fillPct}%` }}
|
||||
transition={{ duration: reduced ? 0 : 0.5, ease: EASE }}
|
||||
/>
|
||||
</div>
|
||||
<ol className="mt-3.5 flex justify-between">
|
||||
{steps.map((s, i) => {
|
||||
const active = i === index;
|
||||
const done = i < index;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Go to ${s.title}`}
|
||||
aria-current={active ? "step" : undefined}
|
||||
className="group flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full border-2 transition-all duration-300 ${
|
||||
active
|
||||
? "scale-110 border-primary bg-primary text-white shadow-[0_6px_16px_rgba(101,20,154,0.3)]"
|
||||
: done
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-primary/20 bg-white text-on-surface-variant/70 group-hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
<MaterialIcon icon={s.icon} size={14} />
|
||||
</span>
|
||||
<span
|
||||
className={`hidden text-[10px] font-black transition-colors duration-300 sm:block ${
|
||||
active ? "text-brand-dark" : "text-on-surface-variant/45"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Controls — manual stepping + replay once the arc has played */}
|
||||
<div className="mt-7 flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous chapter"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-primary/15 text-primary transition-colors hover:bg-primary/[0.06] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<MaterialIcon icon="arrow_back" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={atEnd}
|
||||
aria-label="Next chapter"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-primary/15 text-primary transition-colors hover:bg-primary/[0.06] disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<MaterialIcon icon="arrow_forward" size={18} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{atEnd && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -6 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
type="button"
|
||||
onClick={replay}
|
||||
className="ml-1 inline-flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-sm font-black text-white transition-colors hover:bg-primary-container"
|
||||
>
|
||||
<MaterialIcon icon="replay" size={17} />
|
||||
Replay
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Framed hero canvas — the illustration is the star. Each beat re-mounts, so the
|
||||
// scene's own entrance animation replays and it cross-dissolves into place.
|
||||
function Canvas({ step, reduced }: { step: MerchantStep; reduced: boolean }) {
|
||||
const Scene = MERCHANT_SCENES[step.id];
|
||||
return (
|
||||
<div className="relative aspect-[6/5] w-full overflow-hidden rounded-[28px] border border-primary/10 bg-white shadow-[0_30px_70px_rgba(22,0,29,0.14)]">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={step.id}
|
||||
className="absolute inset-0"
|
||||
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 1.04 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: reduced ? 0.2 : 0.5, ease: EASE }}
|
||||
>
|
||||
<Scene reduced={reduced} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/components/home/merchant/MerchantPhone.tsx
Normal file
235
src/components/home/merchant/MerchantPhone.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import type { MerchantStep, MerchantStageId } from "@/hooks/useMerchantJourney";
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
const screen = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -12 },
|
||||
transition: { duration: 0.55, ease: EASE },
|
||||
};
|
||||
|
||||
// The merchant-side companion device. Its screen mirrors the active stage, so
|
||||
// the illustration and the "real app" always tell the same beat of the story.
|
||||
export function MerchantPhone({ step, reduced }: { step: MerchantStep; reduced: boolean }) {
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full max-w-[250px] items-center justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 scale-110 rounded-full bg-gradient-to-tr from-primary/20 to-[#a855f7]/20 blur-3xl"
|
||||
/>
|
||||
<motion.div
|
||||
animate={reduced ? undefined : { y: [-5, 5, -5] }}
|
||||
transition={{ repeat: Infinity, duration: 6, ease: "easeInOut" }}
|
||||
className="relative z-10 aspect-[9/18.6] w-full select-none overflow-hidden rounded-[40px] border-[10px] border-brand-dark bg-brand-dark shadow-[0_28px_60px_rgba(22,0,29,0.22)]"
|
||||
>
|
||||
<div className="absolute left-1/2 top-2.5 z-30 flex h-4.5 w-20 -translate-x-1/2 items-center justify-end rounded-full bg-brand-dark px-2.5">
|
||||
<span className="mr-0.5 h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="h-1 w-1 rounded-full bg-blue-500/60" />
|
||||
</div>
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-[28px] bg-[#FBF8FE] text-brand-dark">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={step.id} {...screen} className="flex h-full flex-col">
|
||||
{SCREENS[step.id]()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, sub }: { title: string; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-primary px-3.5 pb-2.5 pt-7 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display text-[13px] font-black tracking-tight">nearle</span>
|
||||
<span className="text-[8px] font-black uppercase tracking-widest opacity-80">Merchant</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] font-black">{title}</p>
|
||||
{sub && <p className="text-[9px] font-semibold opacity-80">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ w = "full", tone = "muted" }: { w?: string; tone?: "muted" | "primary" }) {
|
||||
return (
|
||||
<span
|
||||
className={`block h-2 rounded-full ${tone === "primary" ? "bg-primary/70" : "bg-on-surface-variant/25"}`}
|
||||
style={{ width: w }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SCREENS: Record<MerchantStageId, () => React.ReactElement> = {
|
||||
join: () => (
|
||||
<>
|
||||
<Header title="Create your store" sub="Takes about 3 minutes" />
|
||||
<div className="flex flex-1 flex-col gap-2.5 px-3.5 pt-3.5">
|
||||
{["Owner name", "Shop name", "Neighbourhood"].map((f) => (
|
||||
<div key={f} className="rounded-xl border border-primary/12 bg-white px-3 py-2 shadow-sm">
|
||||
<p className="text-[8px] font-bold uppercase tracking-wide text-on-surface-variant/70">{f}</p>
|
||||
<Line w="70%" />
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-auto mb-3.5 flex items-center justify-center gap-1.5 rounded-full bg-primary py-2.5 text-[11px] font-black text-white">
|
||||
<MaterialIcon icon="handshake" size={13} /> Join Nearle
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
digitize: () => (
|
||||
<>
|
||||
<Header title="Store is live" />
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-secondary/12 text-secondary">
|
||||
<MaterialIcon icon="check_circle" size={40} fill />
|
||||
</div>
|
||||
<p className="mt-3 text-[12px] font-black text-brand-dark">Kumar Super Market</p>
|
||||
<p className="mt-1 text-[9px] font-semibold text-on-surface-variant">
|
||||
Your digital storefront is now searchable nearby.
|
||||
</p>
|
||||
<span className="mt-3 rounded-full bg-secondary/10 px-2.5 py-1 text-[8px] font-black uppercase tracking-widest text-secondary">
|
||||
QR verified
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
products: () => (
|
||||
<>
|
||||
<Header title="Your catalogue" sub="18 items live" />
|
||||
<div className="grid flex-1 grid-cols-2 gap-2 px-3.5 pt-3.5">
|
||||
{[
|
||||
["🥛", "Aavin Milk"],
|
||||
["🍞", "Bread"],
|
||||
["🧈", "Butter"],
|
||||
["☕", "Coffee"],
|
||||
].map(([e, n]) => (
|
||||
<div key={n} className="rounded-xl border border-primary/10 bg-white p-2 shadow-sm">
|
||||
<div className="flex h-9 items-center justify-center rounded-lg bg-primary/[0.06] text-base">{e}</div>
|
||||
<p className="mt-1.5 truncate text-[8px] font-black text-brand-dark">{n}</p>
|
||||
<Line w="60%" tone="primary" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="m-3.5 flex items-center justify-center gap-1 rounded-full border border-primary/20 py-2 text-[10px] font-black text-primary">
|
||||
<MaterialIcon icon="add" size={13} /> Add products
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
browse: () => (
|
||||
<>
|
||||
<Header title="Customers nearby" />
|
||||
<div className="px-3.5 py-3">
|
||||
<div className="flex items-center gap-2 rounded-full border border-primary/15 bg-white px-3 py-2 shadow-sm">
|
||||
<MaterialIcon icon="search" size={13} className="text-primary/60" />
|
||||
<span className="text-[10px] font-semibold text-brand-dark">Need breakfast</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 px-3.5">
|
||||
{["🥛 Aavin Milk", "🍞 Sandwich Bread", "🧈 Table Butter"].map((r) => (
|
||||
<div key={r} className="flex items-center justify-between rounded-xl border border-primary/8 bg-white px-2.5 py-2 shadow-sm">
|
||||
<span className="text-[10px] font-black text-brand-dark">{r}</span>
|
||||
<MaterialIcon icon="add_circle" size={15} className="text-primary" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="px-3.5 pb-3.5 pt-2 text-[8px] font-bold text-on-surface-variant">From your store · 700 m away</p>
|
||||
</>
|
||||
),
|
||||
orders: () => (
|
||||
<>
|
||||
<Header title="Orders" sub="1 new" />
|
||||
<div className="px-3.5 pt-3">
|
||||
<div className="rounded-2xl border border-primary/15 bg-white p-3 shadow-[0_10px_24px_rgba(101,20,154,0.12)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<MaterialIcon icon="notifications_active" size={13} />
|
||||
</span>
|
||||
<span className="text-[10px] font-black text-brand-dark">New order · #1042</span>
|
||||
<span className="ml-auto h-2 w-2 rounded-full bg-secondary" />
|
||||
</div>
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
{["Milk × 1", "Bread × 1", "Eggs × 1"].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[9px] font-semibold text-on-surface-variant">
|
||||
<span>{i}</span>
|
||||
<span className="text-primary">✓</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center justify-between border-t border-primary/10 pt-2">
|
||||
<span className="text-[9px] font-bold text-on-surface-variant">Total</span>
|
||||
<span className="font-display text-[13px] font-black text-brand-dark">₹165</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-3.5 mt-auto flex items-center justify-center gap-1.5 rounded-full bg-primary py-2.5 text-[11px] font-black text-white">
|
||||
Accept order
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
packed: () => (
|
||||
<>
|
||||
<Header title="Pack order #1042" sub="Almost ready" />
|
||||
<div className="flex-1 space-y-2 px-3.5 pt-3.5">
|
||||
{[["Aavin Milk", true], ["Sandwich Bread", true], ["Farm Eggs", false]].map(([n, done]) => (
|
||||
<div key={n as string} className="flex items-center gap-2.5 rounded-xl border border-primary/10 bg-white px-3 py-2 shadow-sm">
|
||||
<span
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-md ${done ? "bg-secondary/15 text-secondary" : "border border-on-surface-variant/30 text-transparent"}`}
|
||||
>
|
||||
<MaterialIcon icon="check" size={12} />
|
||||
</span>
|
||||
<span className="text-[10px] font-black text-brand-dark">{n as string}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="m-3.5 flex items-center justify-center gap-1.5 rounded-full bg-primary py-2.5 text-[11px] font-black text-white">
|
||||
<MaterialIcon icon="inventory_2" size={13} /> Mark packed
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
rider: () => (
|
||||
<>
|
||||
<Header title="Out for delivery" sub="Rider assigned" />
|
||||
<div className="relative mx-3.5 mt-3 h-24 overflow-hidden rounded-2xl bg-[#EDE2F4]">
|
||||
<svg viewBox="0 0 200 96" className="h-full w-full">
|
||||
<path d="M0 70 Q60 40 110 60 T200 40" fill="none" stroke="#65149A" strokeWidth="3" strokeDasharray="6 7" strokeLinecap="round" opacity="0.6" />
|
||||
<circle cx="18" cy="70" r="6" fill="#16A34A" />
|
||||
<circle cx="184" cy="40" r="6" fill="#65149A" />
|
||||
</svg>
|
||||
<span className="absolute left-1/2 top-1/2 flex h-9 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-white shadow-lg">
|
||||
<MaterialIcon icon="two_wheeler" size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-3.5 mt-3 rounded-xl border border-primary/10 bg-white px-3 py-2.5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-black text-brand-dark">Arriving in 8 min</span>
|
||||
<span className="rounded-full bg-secondary/12 px-2 py-0.5 text-[8px] font-black uppercase tracking-wide text-secondary">On the way</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
grows: () => (
|
||||
<>
|
||||
<Header title="This week" sub="Revenue up 32%" />
|
||||
<div className="px-3.5 pt-3">
|
||||
<p className="font-display text-2xl font-black text-brand-dark">₹48,200</p>
|
||||
<p className="flex items-center gap-1 text-[9px] font-black text-secondary">
|
||||
<MaterialIcon icon="trending_up" size={12} /> +32% vs last week
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-1 items-end gap-1.5 px-3.5 pb-4">
|
||||
{[34, 46, 40, 58, 52, 70, 88].map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`flex-1 rounded-t-md ${i === 6 ? "bg-primary" : "bg-primary/25"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
};
|
||||
377
src/components/home/merchant/illustrations.tsx
Normal file
377
src/components/home/merchant/illustrations.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import type { MerchantStageId } from "@/hooks/useMerchantJourney";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// One shared 2.5D illustration system for the Merchant Journey.
|
||||
// Every scene sits in the same framed panel, on the same ground shadow, using
|
||||
// the same palette / stroke width / lighting — so the eight feel like one set.
|
||||
// Each scene mounts fresh when its stage becomes active (AnimatePresence key),
|
||||
// so entrance animations simply play on mount. `reduced` freezes all motion.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
const C = {
|
||||
ink: "#2A0A38", // strokes (soft brand-dark)
|
||||
primary: "#65149A",
|
||||
primaryLt: "#7C3AED",
|
||||
accent: "#de96f9",
|
||||
petal: "#F3E8F6",
|
||||
white: "#ffffff",
|
||||
green: "#16A34A",
|
||||
};
|
||||
|
||||
const stroke = {
|
||||
stroke: C.ink,
|
||||
strokeWidth: 3,
|
||||
strokeLinejoin: "round" as const,
|
||||
strokeLinecap: "round" as const,
|
||||
};
|
||||
|
||||
type SceneProps = { reduced?: boolean };
|
||||
|
||||
function Scene({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 360 300"
|
||||
className="h-full w-full"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="mjBg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#F7F0FB" />
|
||||
<stop offset="1" stopColor="#E7D6EE" />
|
||||
</linearGradient>
|
||||
<linearGradient id="mjFace" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={C.primaryLt} />
|
||||
<stop offset="1" stopColor={C.primary} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="360" height="300" rx="30" fill="url(#mjBg)" />
|
||||
<ellipse cx="180" cy="250" rx="120" ry="20" fill={C.primary} opacity="0.1" />
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 01 — Merchant Joins: a shop whose hanging sign gently swings.
|
||||
function StorefrontScene({ reduced }: SceneProps) {
|
||||
return (
|
||||
<Scene>
|
||||
<rect x="92" y="118" width="176" height="118" rx="12" fill={C.white} {...stroke} />
|
||||
{/* awning */}
|
||||
<path d="M84 118 L84 96 Q84 90 90 90 L270 90 Q276 90 276 96 L276 118 Z" fill="url(#mjFace)" {...stroke} />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<rect key={i} x={92 + i * 48} y="90" width="24" height="28" fill={C.white} opacity="0.28" />
|
||||
))}
|
||||
{/* windows + door */}
|
||||
<rect x="108" y="150" width="52" height="44" rx="7" fill={C.petal} {...stroke} />
|
||||
<rect x="200" y="150" width="52" height="44" rx="7" fill={C.petal} {...stroke} />
|
||||
<rect x="160" y="188" width="40" height="48" rx="6" fill={C.accent} opacity="0.5" {...stroke} />
|
||||
<circle cx="192" cy="212" r="2.6" fill={C.ink} />
|
||||
{/* hanging sign — swings from its bar */}
|
||||
<line x1="256" y1="132" x2="290" y2="132" {...stroke} />
|
||||
<motion.g
|
||||
style={{ transformBox: "fill-box", transformOrigin: "top center" }}
|
||||
animate={reduced ? {} : { rotate: [-3.5, 3.5, -3.5] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<line x1="273" y1="132" x2="273" y2="140" {...stroke} />
|
||||
<rect x="252" y="140" width="42" height="26" rx="6" fill={C.primary} {...stroke} />
|
||||
<circle cx="264" cy="153" r="3" fill={C.white} />
|
||||
<rect x="271" y="150" width="16" height="6" rx="3" fill={C.white} />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 02 — Store Digitized: a phone, a sweeping QR scan line, then a success tick.
|
||||
function QrScene({ reduced }: SceneProps) {
|
||||
const cells = [
|
||||
[0, 0], [2, 0], [4, 0], [0, 2], [3, 2], [4, 2], [1, 3], [0, 4], [2, 4], [4, 4],
|
||||
];
|
||||
return (
|
||||
<Scene>
|
||||
<rect x="126" y="66" width="108" height="176" rx="22" fill={C.white} {...stroke} />
|
||||
<rect x="138" y="86" width="84" height="136" rx="10" fill={C.petal} />
|
||||
{/* QR */}
|
||||
<g transform="translate(150 98)">
|
||||
<rect x="0" y="0" width="60" height="60" rx="6" fill={C.white} />
|
||||
{[[0, 0], [44, 0], [0, 44]].map(([x, y], i) => (
|
||||
<rect key={i} x={x} y={y} width="16" height="16" rx="3" fill={C.primary} />
|
||||
))}
|
||||
{cells.map(([cx, cy], i) => (
|
||||
<rect key={`c${i}`} x={20 + cx * 6} y={20 + cy * 6} width="5" height="5" rx="1" fill={C.ink} />
|
||||
))}
|
||||
</g>
|
||||
{/* scan line */}
|
||||
{!reduced && (
|
||||
<motion.rect
|
||||
x="140" width="80" height="4" rx="2" fill={C.primaryLt}
|
||||
initial={{ y: 92, opacity: 0 }}
|
||||
animate={{ y: [98, 214, 98], opacity: [0, 0.9, 0] }}
|
||||
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{/* success tick */}
|
||||
<motion.g
|
||||
initial={reduced ? false : { scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 1, duration: 0.5, ease: EASE }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
>
|
||||
<circle cx="228" cy="80" r="20" fill={C.green} {...stroke} />
|
||||
<path d="M219 80 l6 6 l12 -13" fill="none" stroke={C.white} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 03 — Products Added: product tiles slide up into the phone.
|
||||
function ProductsScene({ reduced }: SceneProps) {
|
||||
const tiles = [
|
||||
{ y: 96, c: C.accent },
|
||||
{ y: 138, c: C.primaryLt },
|
||||
{ y: 180, c: "#F5B54B" },
|
||||
];
|
||||
return (
|
||||
<Scene>
|
||||
<rect x="126" y="66" width="108" height="176" rx="22" fill={C.white} {...stroke} />
|
||||
<rect x="138" y="82" width="84" height="12" rx="6" fill={C.petal} />
|
||||
<clipPath id="mjPhoneClip">
|
||||
<rect x="132" y="90" width="96" height="146" rx="10" />
|
||||
</clipPath>
|
||||
<g clipPath="url(#mjPhoneClip)">
|
||||
{tiles.map((t, i) => (
|
||||
<motion.g
|
||||
key={i}
|
||||
initial={reduced ? false : { y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 0.25 + i * 0.35, duration: 0.6, ease: EASE }}
|
||||
>
|
||||
<rect x="142" y={t.y} width="76" height="34" rx="9" fill={C.white} {...stroke} />
|
||||
<rect x="150" y={t.y + 8} width="18" height="18" rx="5" fill={t.c} />
|
||||
<rect x="176" y={t.y + 9} width="34" height="5" rx="2.5" fill={C.ink} opacity="0.55" />
|
||||
<rect x="176" y={t.y + 19} width="22" height="5" rx="2.5" fill={C.primary} />
|
||||
</motion.g>
|
||||
))}
|
||||
</g>
|
||||
{/* AI + badge */}
|
||||
<motion.g
|
||||
initial={reduced ? false : { scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 0.1, duration: 0.5, ease: EASE }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
>
|
||||
<circle cx="228" cy="80" r="19" fill={C.primary} {...stroke} />
|
||||
<path d="M228 70 l2.6 5.4 l5.8 .8 l-4.2 4 l1 5.8 l-5.2 -2.8 l-5.2 2.8 l1 -5.8 l-4.2 -4 l5.8 -.8 Z" fill={C.white} />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 04 — Customers Browse: a search bar with an expanding pulse + cursor tap.
|
||||
function BrowseScene({ reduced }: SceneProps) {
|
||||
return (
|
||||
<Scene>
|
||||
<rect x="66" y="120" width="228" height="56" rx="28" fill={C.white} {...stroke} />
|
||||
<circle cx="98" cy="148" r="13" fill="none" {...stroke} />
|
||||
<line x1="108" y1="158" x2="118" y2="168" {...stroke} />
|
||||
<rect x="128" y="144" width="96" height="8" rx="4" fill={C.ink} opacity="0.35" />
|
||||
{!reduced && (
|
||||
<motion.circle
|
||||
cx="98" cy="148" fill="none" stroke={C.primaryLt} strokeWidth="3"
|
||||
initial={{ r: 13, opacity: 0.6 }}
|
||||
animate={{ r: [13, 30, 13], opacity: [0.6, 0, 0.6] }}
|
||||
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{/* result chips dropping in */}
|
||||
{[0, 1].map((i) => (
|
||||
<motion.g
|
||||
key={i}
|
||||
initial={reduced ? false : { y: -12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 0.4 + i * 0.25, duration: 0.5, ease: EASE }}
|
||||
>
|
||||
<rect x="82" y={192 + i * 30} width="196" height="24" rx="8" fill={C.white} {...stroke} />
|
||||
<circle cx={98} cy={204 + i * 30} r="6" fill={i === 0 ? C.accent : C.primaryLt} />
|
||||
<rect x="112" y={201 + i * 30} width="120" height="6" rx="3" fill={C.ink} opacity="0.4" />
|
||||
</motion.g>
|
||||
))}
|
||||
{/* cursor */}
|
||||
<motion.g
|
||||
initial={reduced ? false : { x: 20, y: 16, opacity: 0 }}
|
||||
animate={reduced ? {} : { x: [20, 0, 0], y: [16, 0, 2], opacity: 1 }}
|
||||
transition={{ duration: 1.4, ease: EASE, delay: 0.2 }}
|
||||
>
|
||||
<path d="M244 150 l0 26 l7 -7 l6 12 l6 -3 l-6 -12 l10 0 Z" fill={C.ink} stroke={C.white} strokeWidth="2" strokeLinejoin="round" />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 05 — Orders Received: a live order notification pops onto the dashboard.
|
||||
function OrdersScene({ reduced }: SceneProps) {
|
||||
return (
|
||||
<Scene>
|
||||
{/* dashboard */}
|
||||
<rect x="86" y="96" width="188" height="140" rx="16" fill={C.white} {...stroke} />
|
||||
<rect x="104" y="116" width="70" height="9" rx="4.5" fill={C.ink} opacity="0.55" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<g key={i}>
|
||||
<rect x="104" y={140 + i * 26} width="152" height="16" rx="6" fill={C.petal} />
|
||||
<circle cx="114" cy={148 + i * 26} r="4" fill={i === 0 ? C.green : C.primaryLt} />
|
||||
</g>
|
||||
))}
|
||||
{/* toast */}
|
||||
<motion.g
|
||||
initial={reduced ? false : { y: -18, scale: 0.9, opacity: 0 }}
|
||||
animate={{ y: 0, scale: 1, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 0.5, duration: 0.55, ease: EASE }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
>
|
||||
<rect x="150" y="70" width="150" height="48" rx="14" fill={C.primary} {...stroke} />
|
||||
<path d="M172 96 a10 10 0 0 1 20 0 M180 82 v-4 M172 96 h20 M178 100 a4 3 0 0 0 8 0" fill="none" stroke={C.white} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<rect x="206" y="82" width="74" height="7" rx="3.5" fill={C.white} />
|
||||
<rect x="206" y="96" width="52" height="6" rx="3" fill={C.white} opacity="0.7" />
|
||||
<motion.circle
|
||||
cx="290" cy="80" r="6" fill={C.green} stroke={C.white} strokeWidth="2"
|
||||
animate={reduced ? {} : { scale: [1, 1.35, 1] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
||||
/>
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 06 — Products Packed: items drop into an open box.
|
||||
function PackScene({ reduced }: SceneProps) {
|
||||
const items = [
|
||||
{ x: 150, c: C.accent, d: 0.2 },
|
||||
{ x: 178, c: C.primaryLt, d: 0.5 },
|
||||
{ x: 168, c: "#F5B54B", d: 0.8 },
|
||||
];
|
||||
return (
|
||||
<Scene>
|
||||
{/* back flaps */}
|
||||
<path d="M120 150 L150 132 L150 150 Z" fill={C.primaryLt} opacity="0.85" {...stroke} />
|
||||
<path d="M240 150 L210 132 L210 150 Z" fill={C.primaryLt} opacity="0.85" {...stroke} />
|
||||
{/* falling items (behind front face) */}
|
||||
<clipPath id="mjBoxClip"><rect x="120" y="120" width="120" height="96" /></clipPath>
|
||||
<g clipPath="url(#mjBoxClip)">
|
||||
{items.map((it, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
x={it.x} width="30" height="30" rx="7" fill={it.c} {...stroke}
|
||||
initial={reduced ? false : { y: -70, opacity: 0 }}
|
||||
animate={{ y: 158, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : it.d, duration: 0.7, ease: [0.5, 0, 0.6, 1] }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
{/* box front */}
|
||||
<path d="M120 150 L240 150 L232 224 Q231 232 223 232 L137 232 Q129 232 128 224 Z" fill="url(#mjFace)" {...stroke} />
|
||||
<path d="M120 150 L240 150 L236 168 L124 168 Z" fill={C.accent} opacity="0.4" />
|
||||
<rect x="168" y="150" width="24" height="82" fill={C.white} opacity="0.14" />
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 07 — Nearle Rider Delivers: a scooter nudges forward with motion lines.
|
||||
function RiderScene({ reduced }: SceneProps) {
|
||||
return (
|
||||
<Scene>
|
||||
{!reduced &&
|
||||
[0, 1, 2].map((i) => (
|
||||
<motion.line
|
||||
key={i}
|
||||
x1="70" y1={150 + i * 16} x2="104" y2={150 + i * 16}
|
||||
stroke={C.primaryLt} strokeWidth="4" strokeLinecap="round"
|
||||
animate={{ x1: [70, 58], opacity: [0, 0.7, 0] }}
|
||||
transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut", delay: i * 0.15 }}
|
||||
/>
|
||||
))}
|
||||
<motion.g
|
||||
animate={reduced ? {} : { x: [-4, 4, -4], y: [0, -1.5, 0] }}
|
||||
transition={{ duration: 2.6, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
{/* delivery box */}
|
||||
<rect x="132" y="120" width="46" height="42" rx="8" fill={C.primary} {...stroke} />
|
||||
<path d="M140 141 l6 6 l12 -13" fill="none" stroke={C.white} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{/* rider */}
|
||||
<circle cx="212" cy="118" r="16" fill={C.accent} {...stroke} />
|
||||
<path d="M200 120 a12 12 0 0 1 24 0 Z" fill={C.primaryLt} {...stroke} />
|
||||
<path d="M196 150 q6 -26 26 -26 q16 0 18 20" fill="none" {...stroke} />
|
||||
{/* scooter body */}
|
||||
<path d="M176 190 L206 190 L214 162 L188 162 Z" fill="url(#mjFace)" {...stroke} />
|
||||
<line x1="214" y1="162" x2="238" y2="150" {...stroke} />
|
||||
<circle cx="240" cy="148" r="4" fill={C.primary} {...stroke} />
|
||||
{/* wheels */}
|
||||
<circle cx="164" cy="200" r="22" fill={C.white} {...stroke} />
|
||||
<circle cx="164" cy="200" r="8" fill={C.petal} {...stroke} />
|
||||
<circle cx="232" cy="200" r="22" fill={C.white} {...stroke} />
|
||||
<circle cx="232" cy="200" r="8" fill={C.petal} {...stroke} />
|
||||
</motion.g>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
// 08 — Business Grows: bars rise and a trend arrow sweeps up.
|
||||
function GrowthScene({ reduced }: SceneProps) {
|
||||
const bars = [
|
||||
{ x: 108, h: 40 },
|
||||
{ x: 148, h: 66 },
|
||||
{ x: 188, h: 92 },
|
||||
{ x: 228, h: 120 },
|
||||
];
|
||||
return (
|
||||
<Scene>
|
||||
<rect x="82" y="86" width="196" height="150" rx="16" fill={C.white} {...stroke} />
|
||||
<line x1="100" y1="210" x2="262" y2="210" stroke={C.ink} strokeWidth="2.5" strokeLinecap="round" opacity="0.5" />
|
||||
{bars.map((b, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
x={b.x} y={210 - b.h} width="26" height={b.h} rx="6"
|
||||
fill={i === bars.length - 1 ? C.primary : C.accent}
|
||||
stroke={C.ink} strokeWidth="3" strokeLinejoin="round"
|
||||
initial={reduced ? false : { scaleY: 0, opacity: 0.4 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 0.15 + i * 0.18, duration: 0.6, ease: EASE }}
|
||||
style={{ transformBox: "fill-box", transformOrigin: "bottom" }}
|
||||
/>
|
||||
))}
|
||||
{/* trend arrow */}
|
||||
<motion.path
|
||||
d="M104 176 L150 150 L190 160 L256 104"
|
||||
fill="none" stroke={C.primary} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round"
|
||||
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 0.5, duration: 0.9, ease: EASE }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M240 100 L258 102 L252 118"
|
||||
fill="none" stroke={C.primary} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round"
|
||||
initial={reduced ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: reduced ? 0 : 1.3, duration: 0.3 }}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export const MERCHANT_SCENES: Record<MerchantStageId, (p: SceneProps) => React.ReactElement> = {
|
||||
join: StorefrontScene,
|
||||
digitize: QrScene,
|
||||
products: ProductsScene,
|
||||
browse: BrowseScene,
|
||||
orders: OrdersScene,
|
||||
packed: PackScene,
|
||||
rider: RiderScene,
|
||||
grows: GrowthScene,
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export function PremiumNavbar() {
|
||||
aria-label="Nearle Home"
|
||||
>
|
||||
<Image
|
||||
src="/logo-light.png"
|
||||
src="/logos/logo-light.png"
|
||||
alt="Nearle"
|
||||
width={190}
|
||||
height={48}
|
||||
@@ -86,7 +86,7 @@ export function AnimatedDashboard() {
|
||||
transition: {
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
ease: "easeInOut" as const,
|
||||
delay,
|
||||
},
|
||||
},
|
||||
@@ -269,6 +269,7 @@ export function AnimatedDashboard() {
|
||||
height="12"
|
||||
rx="4"
|
||||
fill="#6330D6"
|
||||
initial={{ fill: "#6330D6", opacity: 0.15 }}
|
||||
animate={{
|
||||
fill: stage === "updating" && animatingIndex === 3 ? "#22C55E" : "#6330D6",
|
||||
opacity: stage === "updating" && animatingIndex === 3 ? 0.25 : 0.15,
|
||||
|
||||
@@ -32,7 +32,7 @@ function FoodArt() {
|
||||
// animation restarts and plays from the beginning every time it appears.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src="/Food.svg"
|
||||
src="/images/illustrations/food.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none h-auto w-full max-w-[240px] select-none"
|
||||
|
||||
@@ -67,7 +67,7 @@ const itemVariants = {
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ export function PlatformHero() {
|
||||
transition={{ duration: 1.4, ease: "easeOut" }}
|
||||
>
|
||||
<Image
|
||||
src="/platform.png"
|
||||
src="/images/platform/platform.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
@@ -306,7 +306,7 @@ export function ProductLLMWorkflow() {
|
||||
className="relative"
|
||||
>
|
||||
<Image
|
||||
src="/superhero.png"
|
||||
src="/images/illustrations/superhero.png"
|
||||
alt="ProductLLM building a live catalog"
|
||||
width={1341}
|
||||
height={1533}
|
||||
|
||||
@@ -9,7 +9,7 @@ const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
|
||||
const HAVE: { img: string; label: string; desc: ReactNode }[] = [
|
||||
{
|
||||
img: "/trust.svg",
|
||||
img: "/images/illustrations/trust.svg",
|
||||
label: "Trust",
|
||||
desc: (
|
||||
<>
|
||||
@@ -22,7 +22,7 @@ const HAVE: { img: string; label: string; desc: ReactNode }[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
img: "/customers.svg",
|
||||
img: "/images/illustrations/customers.svg",
|
||||
label: "Customers",
|
||||
desc: (
|
||||
<>
|
||||
@@ -35,7 +35,7 @@ const HAVE: { img: string; label: string; desc: ReactNode }[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
img: "/location.svg",
|
||||
img: "/images/illustrations/location.svg",
|
||||
label: "Location",
|
||||
desc: (
|
||||
<>
|
||||
|
||||
@@ -8,7 +8,7 @@ const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
|
||||
const CARDS: { img: string; label: string; desc: ReactNode }[] = [
|
||||
{
|
||||
img: "/trust.svg",
|
||||
img: "/images/illustrations/trust.svg",
|
||||
label: "Trust",
|
||||
desc: (
|
||||
<>
|
||||
@@ -21,7 +21,7 @@ const CARDS: { img: string; label: string; desc: ReactNode }[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
img: "/customers.svg",
|
||||
img: "/images/illustrations/customers.svg",
|
||||
label: "Community",
|
||||
desc: (
|
||||
<>
|
||||
@@ -34,7 +34,7 @@ const CARDS: { img: string; label: string; desc: ReactNode }[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
img: "/convenience.svg",
|
||||
img: "/images/illustrations/convenience.svg",
|
||||
label: "Convenience",
|
||||
desc: (
|
||||
<>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function WhyNearleHero() {
|
||||
transition={{ duration: 1.4, ease: "easeOut" }}
|
||||
>
|
||||
<Image
|
||||
src="/why-nearle.png"
|
||||
src="/images/why-nearle/why-nearle.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
|
||||
Reference in New Issue
Block a user