update ai commerce section

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

View File

@@ -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}

View File

@@ -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"

View File

@@ -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}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}

View File

@@ -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"

View File

@@ -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"

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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>
</>
);
}

View File

@@ -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"

View File

@@ -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.",

View File

@@ -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}

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

View File

@@ -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 stores 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 whats 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&rsquo;t another delivery app. It&rsquo;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>

View File

@@ -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 youre 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"

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

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

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

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

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

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

View File

@@ -0,0 +1,99 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
// The kinds of things people actually say to Nearle — products *and* local
// services.
//
// Positions are percentages of the phone box, and every card is placed so it
// never covers the live screen — the demo always stays readable:
// · xl+ : three cards drift in the wide gutter left of the phone, two sit
// clear above and below it.
// · below xl there is no gutter, so the visible cards sit above and below
// the device instead of beside it.
const CARDS = [
{
emoji: "🥛",
text: "Order two litres of milk",
pos: "left-[-4%] top-[-7%] xl:left-[-50%] xl:top-[12%]",
drift: { y: [-9, 9, -9], x: [0, 5, 0] },
duration: 15,
delay: 0,
always: true,
},
{
emoji: "💊",
text: "Order medicine for headache",
pos: "right-[-4%] bottom-[-6%] xl:right-[-6%] xl:bottom-[-7%]",
drift: { y: [-10, 7, -10], x: [0, -5, 0] },
duration: 16,
delay: 2.1,
always: true,
},
{
emoji: "🍅",
text: "Need ingredients for biryani",
pos: "right-[-6%] top-[-9%]",
drift: { y: [8, -8, 8], x: [0, -6, 0] },
duration: 18,
delay: 1.4,
always: false,
},
{
emoji: "🔧",
text: "Book an electrician tomorrow",
pos: "left-[-56%] top-[45%]",
drift: { y: [7, -10, 7], x: [0, 6, 0] },
duration: 20,
delay: 0.7,
always: false,
},
{
emoji: "🥣",
text: "Need breakfast for four",
pos: "left-[-52%] top-[77%]",
drift: { y: [9, -7, 9], x: [0, 4, 0] },
duration: 19,
delay: 1,
always: false,
},
];
// Soft glass cards drifting around the phone. Very slow, very quiet — they
// should read as ambient context, never as animation.
export function FloatingRequestCards() {
const reduced = useReducedMotion();
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-20">
{CARDS.map((card, i) => (
<motion.div
key={card.text}
className={`absolute ${card.pos} ${card.always ? "" : "hidden xl:block"}`}
initial={{ opacity: 0, y: 14, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.7, delay: 0.9 + i * 0.12, ease: [0.22, 1, 0.36, 1] }}
>
<motion.div
animate={reduced ? undefined : card.drift}
transition={{
duration: card.duration,
repeat: Infinity,
ease: "easeInOut",
delay: card.delay,
}}
style={{ willChange: "transform" }}
className="flex items-center gap-2 rounded-2xl border border-[#7C3AED]/12 bg-white/75 py-2 pl-2 pr-3.5 shadow-[0_12px_34px_-10px_rgba(76,20,140,0.28)] backdrop-blur-xl"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[#7C3AED]/[0.08] text-[13px]">
{card.emoji}
</span>
<span className="whitespace-nowrap text-[11.5px] font-bold text-[#16001D]">
{card.text}
</span>
</motion.div>
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import Image from "next/image";
import { motion, useInView } from "framer-motion";
import { VoiceScreen, SCREEN_W, SCREEN_H } from "./VoiceScreen";
import { useVoiceJourney } from "@/hooks/useVoiceJourney";
// Geometry read straight off the provided SVG (viewBox 0 0 476 941):
// screen rect x42 y8 w391 h826 rx57. The frame artwork never changes — only
// what we render inside this box does.
const SCREEN = {
left: `${((42 / 476) * 100).toFixed(4)}%`,
top: `${((8 / 941) * 100).toFixed(4)}%`,
width: `${((391 / 476) * 100).toFixed(4)}%`,
height: `${((826 / 941) * 100).toFixed(4)}%`,
// rx57 expressed against the screen's own box: 57/391 wide, 57/826 tall
borderRadius: `${((57 / 391) * 100).toFixed(2)}% / ${((57 / 826) * 100).toFixed(2)}%`,
} as const;
export function PhoneStage({ className = "" }: { className?: string }) {
const journey = useVoiceJourney();
const { start, stop, reduced } = journey;
const rootRef = useRef<HTMLDivElement>(null);
const screenRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0);
// The demo only runs while it is on screen — no timers burning in the
// background once a visitor has scrolled past the hero.
const inView = useInView(rootRef, { amount: 0.25 });
useEffect(() => {
if (inView) start();
else stop();
}, [inView, start, stop]);
// The screen UI is authored at the SVG's own 391×826 units, then scaled to
// whatever size the phone renders at. Same layout everywhere, every
// breakpoint — scaling only.
useLayoutEffect(() => {
const el = screenRef.current;
if (!el) return;
const measure = () => setScale(el.clientWidth / SCREEN_W);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div
ref={rootRef}
className={`relative mx-auto ${className}`}
style={{ perspective: 1600 }}
>
{/* Breathing ground shadow — a separate blurred ellipse so nothing
animates box-shadow (which repaints every frame). */}
<motion.div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 bottom-[3%] h-[9%] w-[78%] -translate-x-1/2 rounded-[50%] bg-[#3B0764]/25 blur-2xl"
animate={reduced ? undefined : { scaleX: [1, 0.9, 1], opacity: [0.5, 0.34, 0.5] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Float — vertical drift */}
<motion.div
animate={reduced ? undefined : { y: [-8, 8, -8] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
style={{ transformStyle: "preserve-3d" }}
>
{/* Sway — slow rotateY on a deliberately different period, so the two
never sync into one obvious loop */}
<motion.div
className="relative w-full"
style={{ aspectRatio: "476 / 941", transformStyle: "preserve-3d" }}
animate={reduced ? undefined : { rotateY: [-1.2, 1.2, -1.2] }}
transition={{ duration: 11, repeat: Infinity, ease: "easeInOut" }}
>
{/* Layer 0 — the provided iPhone SVG, verbatim and permanent */}
<Image
src="/images/hero/iphone-frame.svg"
alt=""
fill
unoptimized
priority
aria-hidden="true"
className="select-none object-contain"
/>
{/* Layer 1 — the live app, clipped exactly to the screen rect */}
<div
ref={screenRef}
className="absolute overflow-hidden"
style={{
left: SCREEN.left,
top: SCREEN.top,
width: SCREEN.width,
height: SCREEN.height,
borderRadius: SCREEN.borderRadius,
}}
>
{scale > 0 && (
<div
style={{
width: SCREEN_W,
height: SCREEN_H,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<VoiceScreen journey={journey} />
</div>
)}
</div>
{/* Layer 2 — notch + status bar, extracted from the same SVG, so the
app sits *under* the notch exactly like real iOS */}
<Image
src="/images/hero/iphone-chrome.svg"
alt=""
fill
unoptimized
aria-hidden="true"
className="pointer-events-none select-none object-contain"
/>
</motion.div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,527 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { AIThinking } from "@/components/home/experience/AIThinking";
import {
AI_STEPS,
FULFILMENT_STEPS,
SERVICE_FULFILMENT_STEPS,
type VoiceJourney,
} from "@/hooks/useVoiceJourney";
// This component is authored in the provided SVG's own screen units — a
// 391 × 826 box — and PhoneStage scales it to whatever size the phone renders
// at. So the layout is pixel-identical on every breakpoint: responsive scaling
// only, never a different (or simpler) UI.
export const SCREEN_W = 391;
export const SCREEN_H = 826;
// The Nearle icon inside the provided SVG: x92 y622 45×45, minus the screen's
// own origin (42, 8). We overlay a matching icon here so it can compress under
// the tap and the app can grow out of exactly the right spot.
const ICON = { x: 50, y: 614, size: 45 } as const;
const ICON_CX = ICON.x + ICON.size / 2; // 72.5
const ICON_CY = ICON.y + ICON.size / 2; // 636.5
// Where the app expands from, as a transform-origin on the 391×826 screen.
const LAUNCH_ORIGIN = `${((ICON_CX / SCREEN_W) * 100).toFixed(2)}% ${((ICON_CY / SCREEN_H) * 100).toFixed(2)}%`;
// Clear of the notch (which ends 30px into the screen) and the status bar.
const SAFE_TOP = 56;
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
// Panels are stacked absolutely and crossfade, so one screen is always fully
// covering the other — the app never flashes empty between beats.
const screenMotion = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE, delay: 0.12 } },
exit: { opacity: 0, y: -8, transition: { duration: 0.28, ease: "easeIn" as const } },
};
export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
const {
stage,
request,
spokenWords,
thinkingStep,
fulfilStep,
reduced,
} = journey;
// The app mounts on "launch" and never unmounts again — the loop returns to
// "listening", not to the home screen — so the iOS expand plays exactly once.
const onHomeScreen = stage === "home" || stage === "tap";
const words = request.transcript.split(" ");
const isBooking = request.domain === "services";
// The request bubble appears the moment the AI takes over and stays put.
const showRequest =
stage !== "launch" && !onHomeScreen && stage !== "listening" && stage !== "speaking";
const fulfilmentSteps = isBooking ? SERVICE_FULFILMENT_STEPS : FULFILMENT_STEPS;
// On the final beat every fulfilment step is done — nothing left pulsing.
const fulfilDone = stage === "delivered" ? fulfilmentSteps.length : fulfilStep;
return (
<div
className="relative overflow-hidden bg-transparent"
style={{ width: SCREEN_W, height: SCREEN_H }}
>
{/* ── iOS home screen: the provided SVG shows through. We only overlay a
matching Nearle icon so it can react to the tap. ── */}
{onHomeScreen && (
<>
<motion.div
className="absolute rounded-[10px] bg-[#7F67BE]"
style={{ left: ICON.x, top: ICON.y, width: ICON.size, height: ICON.size }}
animate={
reduced
? undefined
: stage === "tap"
? { scale: 0.88, opacity: 0.75 }
: { scale: [1, 1.06, 1], opacity: 1 }
}
transition={
stage === "tap"
? { duration: 0.35, ease: EASE }
: { duration: 2, repeat: Infinity, ease: "easeInOut" }
}
>
<span className="absolute inset-0 flex items-center justify-center font-display text-[26px] font-black leading-none text-white">
N
</span>
{/* the icon's own soft highlight, matching the SVG artwork */}
<span className="absolute inset-0 rounded-[10px] bg-white/10" />
</motion.div>
{/* Apple-style touch indicator — a finger tap, never a desktop cursor */}
{!reduced && (
<motion.div
className="absolute rounded-full border-2 border-white/70 bg-white/25"
style={{ width: 34, height: 34, left: ICON_CX - 17, top: ICON_CY - 17 }}
initial={{ opacity: 0, scale: 1.9 }}
animate={
stage === "home"
? { opacity: [0, 0.55], scale: [1.9, 1.25] }
: { opacity: [0.9, 0], scale: [0.85, 1.6] }
}
transition={{ duration: stage === "home" ? 1.1 : 0.5, ease: EASE }}
/>
)}
</>
)}
{/* ── The Nearle app ── */}
{!onHomeScreen && (
<motion.div
className="absolute inset-0 flex flex-col overflow-hidden text-white"
style={{
transformOrigin: LAUNCH_ORIGIN,
background:
"radial-gradient(120% 70% at 50% 0%, #2A0846 0%, #180329 45%, #0D0116 100%)",
}}
initial={
reduced ? false : { scale: ICON.size / SCREEN_W, opacity: 0, borderRadius: 88 }
}
animate={{ scale: 1, opacity: 1, borderRadius: 0 }}
transition={{ duration: 0.62, ease: EASE }}
>
{/* ambient purple bloom */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-[320px] w-[320px] -translate-x-1/2 rounded-full opacity-60 blur-[70px]"
style={{ background: "radial-gradient(circle, #A855F7 0%, transparent 70%)" }}
/>
{/* App header */}
<div
className="relative flex shrink-0 items-center justify-between px-6"
style={{ paddingTop: SAFE_TOP }}
>
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-[9px] bg-[#7F67BE] font-display text-[15px] font-black leading-none">
N
</span>
<span className="font-display text-[15px] font-black tracking-tight">Nearle</span>
</div>
<span className="flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[10px] font-bold text-white/80">
<MaterialIcon icon="location_on" size={11} />
R.S. Puram
</span>
</div>
{/* What the customer said, kept on screen for the rest of the flow —
so the link between the sentence and everything the AI does with
it stays visible the whole way to the door. */}
{showRequest && (
<motion.div
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: EASE }}
className="relative flex shrink-0 justify-end px-6 pt-5"
>
<div className="max-w-[82%] rounded-[18px] rounded-br-[6px] bg-gradient-to-br from-[#A855F7] to-[#7C3AED] px-3.5 py-2.5 shadow-[0_8px_22px_-8px_rgba(168,85,247,0.8)]">
<span className="mb-0.5 flex items-center justify-end gap-1 text-[8.5px] font-black uppercase tracking-[0.15em] text-white/60">
<MaterialIcon icon="mic" size={9} />
You said
</span>
<p className="text-[12.5px] font-bold leading-snug">{request.transcript}</p>
</div>
</motion.div>
)}
{/* Screen body */}
<div className="relative flex min-h-0 flex-1 flex-col">
<AnimatePresence initial={false}>
{(stage === "launch" || stage === "listening" || stage === "speaking") && (
<motion.div
key="voice"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col items-center justify-center px-7"
>
<MicOrb reduced={reduced} active={stage === "speaking"} />
<p className="mt-9 text-[11px] font-black uppercase tracking-[0.2em] text-[#C9A2FF]">
{stage === "speaking" ? "Listening" : "Listening…"}
</p>
{/* The spoken request, landing word by word */}
<div className="mt-3 flex min-h-[62px] w-full items-start justify-center">
{stage === "speaking" ? (
<p className="text-center font-display text-[21px] font-black leading-[1.28] tracking-tight">
{words.map((w, i) => (
<motion.span
key={`${request.id}-${i}`}
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={
i < spokenWords ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }
}
transition={{ duration: 0.28, ease: EASE }}
className="inline-block"
>
{w}&nbsp;
</motion.span>
))}
</p>
) : (
<p className="text-center text-[13px] font-semibold leading-relaxed text-white/55">
Speak naturally.
<br />
No apps, no searching.
</p>
)}
</div>
<Waveform reduced={reduced} active={stage === "speaking"} />
</motion.div>
)}
{stage === "thinking" && (
<motion.div
key="thinking"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
>
<div className="rounded-[22px] border border-white/10 bg-white/[0.06] p-5 backdrop-blur-sm">
<p className="mb-4 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
<MaterialIcon icon="auto_awesome" size={13} />
Nearle AI
</p>
<AIThinking steps={AI_STEPS} activeStep={thinkingStep} tone="dark" />
</div>
</motion.div>
)}
{stage === "merchant" && (
<motion.div
key="merchant"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
>
<p className="mb-3 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
<MaterialIcon icon="near_me" size={13} />
Found nearby
</p>
<motion.div
initial={reduced ? false : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.55, ease: EASE }}
className="rounded-[22px] border border-white/12 bg-white/[0.07] p-5"
>
<div className="flex items-start gap-3.5">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[#A855F7]/20 text-[#C9A2FF]">
<MaterialIcon icon={request.merchant.icon} size={24} />
</span>
<div className="min-w-0 flex-1">
<p className="font-display text-[16px] font-black leading-tight tracking-tight">
{request.merchant.name}
</p>
<p className="mt-1 text-[11px] font-semibold text-white/55">
{request.merchant.area} · {request.merchant.distance}
</p>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<span className="flex items-center gap-1 rounded-full bg-emerald-400/15 px-2.5 py-1 text-[10px] font-black text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Open now
</span>
<span className="flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[10px] font-black text-white/80">
<MaterialIcon icon="star" size={11} fill />
{request.merchant.rating}
</span>
<span className="rounded-full bg-white/10 px-2.5 py-1 text-[10px] font-black text-white/80">
Verified
</span>
</div>
</motion.div>
</motion.div>
)}
{stage === "order" && (
<motion.div
key="order"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
>
<p className="mb-2.5 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-[#C9A2FF]">
<MaterialIcon icon="auto_awesome" size={13} />
{isBooking ? "Booking created" : "Order created"}
</p>
<div className="flex items-center gap-2 text-[11px] font-bold text-white/70">
<MaterialIcon icon={request.merchant.icon} size={13} />
<span className="truncate">{request.merchant.name}</span>
</div>
<div className="mt-3 space-y-2">
{request.items.map((item, i) => (
<motion.div
key={item.name}
initial={reduced ? false : { opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: reduced ? 0 : 0.1 + i * 0.13, duration: 0.45, ease: EASE }}
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.06] px-3 py-2.5"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/[0.07] text-[17px]">
{item.emoji}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-black leading-tight">
{item.name}
</span>
<span className="mt-0.5 flex items-center gap-1.5">
<span className="text-[10px] font-semibold text-white/50">
{item.qty}
</span>
<span className="flex items-center gap-0.5 text-[9.5px] font-black text-emerald-300">
<span className="h-1 w-1 rounded-full bg-emerald-400" />
{item.status}
</span>
</span>
</span>
<span className="shrink-0 text-[13px] font-black">{item.price}</span>
</motion.div>
))}
</div>
<div className="mt-3 shrink-0 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-3">
<div className="flex items-end justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-white/50">
Total
</span>
<span className="font-display text-[21px] font-black leading-none">
{request.total}
</span>
</div>
<div className="mt-1.5 flex items-center gap-1 text-[10px] font-bold text-white/50">
<MaterialIcon icon="schedule" size={11} />
{request.eta}
</div>
</div>
{/* Confirm auto-taps at the end of this beat */}
<motion.div
animate={reduced ? undefined : { scale: [1, 1, 0.95, 1] }}
transition={{ duration: 2.6, times: [0, 0.76, 0.84, 0.92], ease: EASE }}
className="mt-3 flex shrink-0 items-center justify-center gap-1.5 rounded-full bg-white py-3.5 text-[13px] font-black text-[#2A0846]"
>
<MaterialIcon icon="bolt" size={15} />
{request.cta}
</motion.div>
</motion.div>
)}
{stage === "confirmed" && (
<motion.div
key="confirmed"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col items-center justify-center px-8"
>
<motion.div
initial={reduced ? false : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 18 }}
className="relative flex h-[86px] w-[86px] items-center justify-center rounded-full bg-emerald-400/15"
>
{!reduced && (
<motion.span
className="absolute inset-0 rounded-full border-2 border-emerald-400/50"
animate={{ scale: [1, 1.6], opacity: [0.7, 0] }}
transition={{ duration: 1.1, repeat: Infinity, ease: "easeOut" }}
/>
)}
<MaterialIcon icon="check" size={44} className="text-emerald-300" />
</motion.div>
<p className="mt-6 text-center font-display text-[20px] font-black tracking-tight">
{isBooking ? "Booking confirmed" : "Order confirmed"}
</p>
<p className="mt-2 text-center text-[12px] font-semibold text-white/55">
Sent to {request.merchant.name}
</p>
</motion.div>
)}
{(stage === "fulfilment" || stage === "delivered") && (
<motion.div
key="fulfilment"
{...screenMotion}
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
>
<p className="mb-4 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-emerald-300">
<MaterialIcon icon="two_wheeler" size={13} />
{stage === "delivered"
? isBooking
? "Job completed"
: "Delivered"
: "On the way"}
</p>
<div className="relative space-y-3.5 pl-1">
{/* connector rail */}
<span
aria-hidden="true"
className="absolute left-[13px] top-3 bottom-3 w-px bg-white/12"
/>
{fulfilmentSteps.map((step, i) => {
const done = i < fulfilDone;
const active = i === fulfilDone;
return (
<div
key={step.label}
className={`relative flex items-center gap-3 transition-opacity duration-300 ${
i <= fulfilDone ? "opacity-100" : "opacity-35"
}`}
>
<span
className={`relative z-10 flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full ${
done || active
? "bg-emerald-400/20 text-emerald-300"
: "bg-white/10 text-white/60"
}`}
>
{done ? (
<MaterialIcon icon="check" size={14} />
) : active && !reduced ? (
<motion.span
className="h-2 w-2 rounded-full bg-emerald-400"
animate={{ scale: [1, 1.5, 1], opacity: [1, 0.5, 1] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "easeInOut" }}
/>
) : (
<MaterialIcon icon={step.icon} size={13} />
)}
</span>
<span className="text-[12.5px] font-black">{step.label}</span>
</div>
);
})}
</div>
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.06] px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-white/45">
{isBooking ? "Professional from" : "Packed by"}
</p>
<p className="mt-1 truncate font-display text-[13.5px] font-black">
{request.merchant.name}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Persistent mic bar — the app is always one sentence away */}
<div className="relative shrink-0 px-6 pb-7">
<div className="flex items-center gap-3 rounded-full border border-white/10 bg-white/[0.07] py-2.5 pl-3 pr-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#A855F7] to-[#7C3AED]">
<MaterialIcon icon="mic" size={16} className="text-white" />
</span>
<span className="truncate text-[11.5px] font-bold text-white/55">
{stage === "listening" ? "Listening…" : "Ask Nearle anything"}
</span>
</div>
</div>
</motion.div>
)}
</div>
);
}
// The listening orb: a soft purple core with breathing rings.
function MicOrb({ reduced, active }: { reduced: boolean; active: boolean }) {
return (
<div className="relative flex h-[104px] w-[104px] items-center justify-center">
{!reduced &&
[0, 1, 2].map((i) => (
<motion.span
key={i}
className="absolute inset-0 rounded-full border border-[#C9A2FF]/40"
animate={{ scale: [1, active ? 1.75 : 1.5], opacity: [0.55, 0] }}
transition={{
duration: active ? 1.8 : 2.4,
repeat: Infinity,
ease: "easeOut",
delay: i * (active ? 0.6 : 0.8),
}}
/>
))}
<motion.span
className="flex h-[68px] w-[68px] items-center justify-center rounded-full shadow-[0_10px_40px_rgba(168,85,247,0.45)]"
style={{ background: "linear-gradient(145deg,#C084FC 0%,#7C3AED 100%)" }}
animate={reduced ? undefined : { scale: active ? [1, 1.06, 1] : [1, 1.03, 1] }}
transition={{ duration: active ? 0.9 : 2.2, repeat: Infinity, ease: "easeInOut" }}
>
<MaterialIcon icon="mic" size={30} className="text-white" />
</motion.span>
</div>
);
}
// Live voice bars. Heights are fixed per bar so the pattern reads as a real
// waveform rather than random noise; only scaleY animates.
const BARS = [0.35, 0.6, 0.85, 1, 0.7, 0.45, 0.8, 1, 0.65, 0.4, 0.75, 0.55, 0.3];
function Waveform({ reduced, active }: { reduced: boolean; active: boolean }) {
return (
<div className="mt-7 flex h-9 items-center justify-center gap-[5px]">
{BARS.map((h, i) => (
<motion.span
key={i}
className="w-[3px] rounded-full bg-gradient-to-t from-[#7C3AED] to-[#C9A2FF]"
style={{ height: 36 * h, originY: 0.5 }}
animate={
reduced
? { scaleY: 0.5 }
: { scaleY: active ? [0.32, 1, 0.5, 0.85, 0.32] : [0.22, 0.42, 0.22] }
}
transition={{
duration: active ? 1.1 : 1.9,
repeat: Infinity,
ease: "easeInOut",
delay: i * 0.07,
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
// A 2400-wide path holding six 400px periods. Sliding it exactly -1200
// (three periods) loops seamlessly, so the drift never visibly restarts.
const WAVE =
"M0 150 Q100 80 200 150 T400 150 T600 150 T800 150 T1000 150 T1200 150 T1400 150 T1600 150 T1800 150 T2000 150 T2200 150 T2400 150";
const LAYERS = [
{ y: 0, scaleY: 1, opacity: 0.18, duration: 12, width: 2 },
{ y: 26, scaleY: 0.62, opacity: 0.13, duration: 16, width: 1.5 },
{ y: -22, scaleY: 1.35, opacity: 0.1, duration: 21, width: 1.5 },
];
// Elegant voice waves behind the phone. Purely decorative, low opacity, and
// animated on transform only — it should reinforce "this is voice" without
// ever pulling attention off the screen.
export function VoiceWaves({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 overflow-hidden ${className}`}
style={{
maskImage: "radial-gradient(60% 55% at 50% 50%, #000 35%, transparent 100%)",
WebkitMaskImage: "radial-gradient(60% 55% at 50% 50%, #000 35%, transparent 100%)",
}}
>
{LAYERS.map((layer, i) => (
<motion.svg
key={i}
viewBox="0 0 2400 300"
preserveAspectRatio="none"
className="absolute left-1/2 top-1/2 h-[52%] w-[200%]"
style={{
x: "-50%",
y: `calc(-50% + ${layer.y}px)`,
opacity: layer.opacity,
}}
animate={reduced ? undefined : { translateX: ["0%", "-25%"] }}
transition={{ duration: layer.duration, repeat: Infinity, ease: "linear" }}
>
<defs>
<linearGradient id={`voice-wave-${i}`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#7C3AED" stopOpacity="0" />
<stop offset="35%" stopColor="#A855F7" stopOpacity="1" />
<stop offset="65%" stopColor="#C084FC" stopOpacity="1" />
<stop offset="100%" stopColor="#7C3AED" stopOpacity="0" />
</linearGradient>
</defs>
<path
d={WAVE}
fill="none"
stroke={`url(#voice-wave-${i})`}
strokeWidth={layer.width}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
transform={`translate(0 ${150 - 150 * layer.scaleY}) scale(1 ${layer.scaleY})`}
/>
</motion.svg>
))}
</div>
);
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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],
},
},
};

View File

@@ -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"

View File

@@ -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}

View File

@@ -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: (
<>

View File

@@ -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: (
<>

View File

@@ -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"