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

@@ -2,10 +2,10 @@ import React from "react";
import Image from "next/image";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { AboutHero } from "@/components/heroes/AboutHero";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/motion/AnimatedGrid";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
import { FloatingDelivery } from "@/components/about/FloatingDelivery";
import type { Metadata } from "next";
@@ -20,7 +20,7 @@ const STRATEGY_CARDS = [
descr: "To help average stores perform like the great ones, not just by selling products, but by building a strong brand identity and real relationships with customers through Nearle.",
icon: "target",
iconClass: "bg-[#6330D6]/[0.07] text-[#6330D6] border-[#6330D6]/10",
image: "/images/nearle-how/how-storefront.webp",
image: "/images/workflow/how-storefront.webp",
imageAlt: "3D local store storefront with purple awning",
imgFit: "object-contain object-bottom"
},
@@ -29,7 +29,7 @@ const STRATEGY_CARDS = [
descr: "Our vision is to make every neighbourhood more connected by helping people discover, support, and shop from the local businesses around them.",
icon: "visibility",
iconClass: "bg-secondary/[0.07] text-secondary border-secondary/10",
image: "/images/nearle-merchant/local-map.webp",
image: "/images/stores/local-map.webp",
imageAlt: "Nearle local neighbourhood map",
imgFit: "object-cover object-center"
},
@@ -38,7 +38,7 @@ const STRATEGY_CARDS = [
descr: "Nearle is essential for a business to grow by leveraging the full potential of its neighbourhood. It is an indispensable tool for consumers to take advantage of what their neighbourhood has to offer.",
icon: "help_outline",
iconClass: "bg-[#6330D6]/10 text-[#6330D6] border-[#6330D6]/20",
image: "/images/app-mockup.webp",
image: "/images/platform/app-mockup.webp",
imageAlt: "Nearle app on a smartphone",
imgFit: "object-contain object-bottom"
}

View File

@@ -2,11 +2,11 @@ import React from "react";
import Image from "next/image";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { BrandsHero } from "@/components/heroes/BrandsHero";
import { FAQAccordion } from "@/components/ui/FAQAccordion";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/motion/AnimatedGrid";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
import { CTABanner } from "@/components/ui/CTABanner";
import { NetworkBackground } from "@/components/brands/NetworkBackground";
import { PartnershipConnector } from "@/components/brands/PartnershipConnector";
@@ -199,7 +199,7 @@ export default function BrandsPage() {
{/* ─── Industries ─── */}
<section className="relative overflow-hidden py-10 md:py-14 xl:py-12 bg-[#FAF7FD] border-b border-[#6330D6]/10">
<Image
src="/d2c bg1.png"
src="/images/illustrations/d2c-bg1.png"
alt=""
fill
priority={false}
@@ -215,7 +215,7 @@ export default function BrandsPage() {
<div className="relative mx-auto h-[280px] w-full max-w-[420px] sm:h-[360px] lg:mx-0 lg:h-[440px]">
<div className="absolute left-0 top-0 h-[62%] w-[72%] overflow-hidden rounded-[28px] border-4 border-white shadow-[0_20px_50px_rgba(22,0,29,0.16)]">
<Image
src="/images/cat-daily-essentials.webp"
src="/images/stores/cat-daily-essentials.webp"
alt="Daily essentials like milk, bread, and fresh produce stocked on Nearle stores"
fill
sizes="(max-width: 1023px) 300px, 22vw"
@@ -224,7 +224,7 @@ export default function BrandsPage() {
</div>
<div className="absolute right-0 top-[6%] h-[42%] w-[52%] overflow-hidden rounded-[22px] border-4 border-white shadow-[0_16px_40px_rgba(22,0,29,0.14)]">
<Image
src="/images/cat-health-wellness.webp"
src="/images/stores/cat-health-wellness.webp"
alt="Health and personal care products stocked on Nearle stores"
fill
sizes="(max-width: 1023px) 220px, 16vw"
@@ -233,7 +233,7 @@ export default function BrandsPage() {
</div>
<div className="absolute bottom-0 left-[10%] h-[38%] w-[54%] overflow-hidden rounded-[22px] border-4 border-white shadow-[0_16px_40px_rgba(22,0,29,0.14)]">
<Image
src="/images/cat-pet-essentials.webp"
src="/images/stores/cat-pet-essentials.webp"
alt="Pet essentials brand stocked on Nearle stores"
fill
sizes="(max-width: 1023px) 220px, 16vw"
@@ -329,7 +329,7 @@ export default function BrandsPage() {
<div className="absolute -right-4 -bottom-4 h-24 w-24 rounded-full bg-secondary/20 blur-3xl -z-10" />
<div className="relative h-[220px] sm:h-[260px] rounded-[28px] overflow-hidden shadow-[0_20px_50px_rgba(22,0,29,0.16)]">
<Image
src="/images/nearle-merchant/success-owner.webp"
src="/images/stores/success-owner.webp"
alt="Neighbourhood store owner reviewing brand orders on his phone"
fill
sizes="(max-width: 1023px) 90vw, 360px"
@@ -373,7 +373,7 @@ export default function BrandsPage() {
"circle at 70% 30%, rgba(99,48,214,0.2), transparent 50%",
"circle at 20% 70%, rgba(0,109,55,0.12), transparent 45%",
]}
backgroundImage="/brand part.png"
backgroundImage="/images/illustrations/brand-part.png"
/>
</PremiumPageShell>
);

View File

@@ -2,11 +2,11 @@ import React from "react";
import Image from "next/image";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { BusinessFeaturesGrid } from "@/components/businesses/BusinessFeaturesGrid";
import { BusinessHero } from "@/components/heroes/BusinessHero";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/motion/AnimatedGrid";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
import { CTABanner } from "@/components/ui/CTABanner";
import type { Metadata } from "next";
@@ -177,7 +177,7 @@ export default function BusinessesPage() {
<div className="relative z-0 mt-auto pt-5 h-[170px] lg:h-[185px] flex items-end justify-center">
<div className="relative h-[150px] w-[220px] lg:h-[175px] lg:w-[255px]">
<Image
src="/images/nearle-how/how-storefront.webp"
src="/images/workflow/how-storefront.webp"
alt="Local shop storefront with purple awning and now online board"
fill
sizes="(max-width: 768px) 50vw, 25vw"
@@ -234,7 +234,7 @@ export default function BusinessesPage() {
</div>
<div className="relative h-[150px] w-[190px] lg:h-[175px] lg:w-[215px] shrink-0">
<Image
src="/images/nearle-how/how-grocery-bag.webp"
src="/images/workflow/how-grocery-bag.webp"
alt="Nearle grocery bag filled with vegetables and packaged products"
fill
sizes="(max-width: 768px) 50vw, 25vw"

View File

@@ -1,8 +1,8 @@
import React from "react";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { ContactHero } from "@/components/heroes/ContactHero";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import type { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,7 +1,7 @@
import React from "react";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import type { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,11 +1,11 @@
import React from "react";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { DeliveryHero } from "@/components/heroes/DeliveryHero";
import { FAQAccordion } from "@/components/ui/FAQAccordion";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/motion/AnimatedGrid";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
import { AnimatedTimeline } from "@/components/delivery/AnimatedTimeline";
import { CTABanner } from "@/components/ui/CTABanner";
import type { Metadata } from "next";

View File

@@ -1040,3 +1040,18 @@ main {
border-radius: 1.25rem;
background-color: var(--brand-accent-light);
}
/* Reduced motion: Framer components are handled by <MotionConfig reducedMotion="user">.
This covers CSS-only motion it can't reach — Tailwind transitions/hover transforms
and the infinite keyframe loops (float, pulse-subtle, hero-bike-float, stroke-dash,
hero-map-drift). Near-instant rather than removed, so final states stay intact. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,9 +1,9 @@
import type { Metadata, Viewport } from "next";
import { Lato } from "next/font/google";
import "./globals.css";
import { MotionProvider } from "@/components/layout/MotionProvider";
import { PremiumNavbar } from "@/components/layout/PremiumNavbar";
import { PremiumFooter } from "@/components/layout/PremiumFooter";
import { MotionProvider } from "@/components/common/MotionProvider";
import { PremiumNavbar } from "@/components/navbar/PremiumNavbar";
import { PremiumFooter } from "@/components/footer/PremiumFooter";
const lato = Lato({
subsets: ["latin"],
@@ -41,7 +41,7 @@ export const metadata: Metadata = {
type: "website",
images: [
{
url: "/logo.png",
url: "/logos/logo.png",
width: 512,
height: 512,
alt: "Nearle logo",
@@ -53,13 +53,13 @@ export const metadata: Metadata = {
title: "Nearle: Your Neighborhood, Delivered",
description:
"India's hyperlocal neighborhood commerce platform",
images: ["/logo.png"],
images: ["/logos/logo.png"],
},
alternates: { canonical: "/" },
icons: {
icon: "/favicon.ico",
shortcut: "/favicon.ico",
apple: "/apple-icon.png",
apple: "/icons/apple-icon.png",
},
robots: { index: true, follow: true },
};
@@ -78,7 +78,7 @@ const jsonLd = {
"@id": "https://nearle.in/#organization",
name: "Nearle",
url: "https://nearle.in",
logo: "https://nearle.in/logo.png",
logo: "https://nearle.in/logos/logo.png",
description:
"Nearle is an AI Commerce platform that digitizes neighbourhood stores — with AI catalogs by ProductLLM, QR ordering, and local discovery — instead of replacing them.",
sameAs: [
@@ -119,7 +119,7 @@ export default function RootLayout({
<MotionProvider>
<div className="min-h-screen bg-white font-body text-[#16001D] antialiased flex flex-col">
<PremiumNavbar />
<main className="w-full overflow-x-hidden bg-white">
<main className="w-full overflow-x-clip bg-white">
{children}
</main>
<PremiumFooter />

View File

@@ -1,11 +1,11 @@
import React from "react";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { MerchantsHero } from "@/components/heroes/MerchantsHero";
import { FAQAccordion } from "@/components/ui/FAQAccordion";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/motion/AnimatedGrid";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
import { CTABanner } from "@/components/ui/CTABanner";
import type { Metadata } from "next";

View File

@@ -1,18 +1,21 @@
import type { Metadata } from "next";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { Hero } from "@/components/home/Hero";
import { VoiceCommerceExperience } from "@/components/home/VoiceCommerceExperience";
import { StoreJourney } from "@/components/home/experience/StoreJourney";
import { ServiceCategories } from "@/components/home/ServiceCategories";
import { WhyNearle } from "@/components/home/WhyNearle";
import { HowItWorks } from "@/components/home/HowItWorks";
import { HyperlocalFeatures } from "@/components/home/HyperlocalFeatures";
import { BusinessSection } from "@/components/home/BusinessSection";
import { WhyAICommerceWins } from "@/components/home/WhyAICommerceWins";
import { AICommerceTeaser } from "@/components/home/AICommerceTeaser";
import { StoreQRSection } from "@/components/home/StoreQRSection";
export const metadata: Metadata = {
title: "AI Commerce Platform for Neighbourhood Stores",
description:
"Nearle is the AI Commerce platform that digitizes neighbourhood stores instead of replacing them. AI catalogs by ProductLLM, QR ordering, and local discovery — commerce that runs on your neighbourhood.",
"Nearle digitizes the neighbourhood stores you already trust. Shop from nearby merchants, let Nearle AI find products by natural language, and get orders packed by the shopkeeper and delivered by a Nearle rider.",
openGraph: {
title: "Nearle — AI Commerce Platform for Neighbourhood Stores",
description:
@@ -26,12 +29,15 @@ export default function HomePage() {
return (
<PremiumPageShell>
<Hero />
<VoiceCommerceExperience />
<StoreJourney />
<ServiceCategories />
<WhyNearle />
<HowItWorks />
<HyperlocalFeatures />
<BusinessSection />
<WhyAICommerceWins />
<AICommerceTeaser />
<StoreQRSection />
</PremiumPageShell>
);

View File

@@ -1,12 +1,12 @@
import React from "react";
import Image from "next/image";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { PeopleHero } from "@/components/heroes/PeopleHero";
import { AnimatedSection } from "@/components/motion/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/motion/AnimatedGrid";
import { AnimatedSection } from "@/components/animations/AnimatedSection";
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
import { AnimatedTimeline } from "@/components/delivery/AnimatedTimeline";
import { siteContent } from "@/config/site-content";
import { siteContent } from "@/constants/site-content";
import type { Metadata } from "next";
export const metadata: Metadata = {
@@ -79,7 +79,7 @@ const OFFERINGS = [
descr: "Enjoy the comfort of elegant living. Nearle is India's first hyper-local managed platform bringing all amenities and requirements directly to your fingertips.",
icon: "local_florist",
color: "from-purple-500/10 to-indigo-500/10 text-primary border-purple-100",
image: "/nearle-people.webp",
image: "/images/hero/nearle-people.webp",
isWide: false
},
{
@@ -88,7 +88,7 @@ const OFFERINGS = [
descr: "Pick hot deals within your neighbourhood. Stay updated on localized sales, discounts, and flash promotions happening today.",
icon: "local_activity",
color: "from-amber-500/10 to-orange-500/10 text-amber-700 border-amber-100",
image: "/images/app-mockup.webp",
image: "/images/platform/app-mockup.webp",
isWide: false
},
{
@@ -97,7 +97,7 @@ const OFFERINGS = [
descr: "Enjoy the ease of the World Wide Web, fulfilled entirely by trusted local businesses. Avoid traffic lights, parking hassles, and pollution.",
icon: "travel_explore",
color: "from-green-500/10 to-emerald-500/10 text-secondary border-green-100",
image: "/super.png",
image: "/images/illustrations/super.png",
isWide: false
},
{
@@ -106,7 +106,7 @@ const OFFERINGS = [
descr: "Enjoy safe, convenient shopping with custom pricing, resident promotions, and bulk order discounts negotiated for your community.",
icon: "shopping_bag",
color: "from-rose-500/10 to-pink-500/10 text-rose-700 border-rose-100",
image: "/images/cat-pet-essentials.webp",
image: "/images/stores/cat-pet-essentials.webp",
isWide: false
},
{
@@ -115,7 +115,7 @@ const OFFERINGS = [
descr: "Order everything you need in your neighborhood with a single tap. A fast, organized last-mile delivery network ensures prompt fulfillment.",
icon: "touch_app",
color: "from-sky-500/10 to-blue-500/10 text-sky-700 border-sky-100",
image: "/about.webp",
image: "/images/hero/about.webp",
isWide: true
},
{
@@ -124,7 +124,7 @@ const OFFERINGS = [
descr: "Get your fresh fruits, farm-fresh vegetables, dairy, and grocery items delivered instantly from verified local green grocers.",
icon: "eco",
color: "from-emerald-500/10 to-teal-500/10 text-emerald-700 border-emerald-100",
image: "/images/cat-daily-essentials.webp",
image: "/images/stores/cat-daily-essentials.webp",
isWide: false
},
{
@@ -133,7 +133,7 @@ const OFFERINGS = [
descr: "Choose from the best local restaurants and neighborhood eateries close to your home. Hot food delivered with relevance and speed.",
icon: "restaurant",
color: "from-orange-500/10 to-red-500/10 text-orange-700 border-orange-100",
image: "/images/cat-hot-food.webp",
image: "/images/stores/cat-hot-food.webp",
isWide: false
},
{
@@ -142,7 +142,7 @@ const OFFERINGS = [
descr: "Access custom-tailored local neighborhood services designed to integrate convenience directly into residential society living.",
icon: "stars",
color: "from-violet-500/10 to-purple-500/10 text-violet-700 border-violet-100",
image: "/images/cat-health-wellness.webp",
image: "/images/stores/cat-health-wellness.webp",
isWide: false
},
{
@@ -151,7 +151,7 @@ const OFFERINGS = [
descr: "Book certified local service providers at your convenience. From handymen to laundry, pet care to pharmacy delivery, all in one place.",
icon: "construction",
color: "from-cyan-500/10 to-blue-500/10 text-cyan-700 border-cyan-100",
image: "/images/cat-home-services.webp",
image: "/images/stores/cat-home-services.webp",
isWide: false
}
];

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { PlatformHero } from "@/components/platform/PlatformHero";
import { PlatformArchitecture } from "@/components/platform/PlatformArchitecture";
import { ProductFeatureGrid } from "@/components/platform/ProductFeatureGrid";

View File

@@ -1,7 +1,7 @@
import React from "react";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import type { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,7 +1,7 @@
import React from "react";
import Link from "next/link";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import type { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { PremiumPageShell } from "@/components/layout/PremiumPageShell";
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
import { WhyNearleHero } from "@/components/why-nearle/WhyNearleHero";
import { CommerceEvolution } from "@/components/why-nearle/CommerceEvolution";
import { AICommerceExplainer } from "@/components/why-nearle/AICommerceExplainer";

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"

View File

@@ -0,0 +1,7 @@
[
{ "id": "groceries", "name": "Groceries", "icon": "shopping_basket" },
{ "id": "dairy", "name": "Dairy", "icon": "egg" },
{ "id": "snacks", "name": "Snacks", "icon": "cookie" },
{ "id": "household", "name": "Household", "icon": "cleaning_services" },
{ "id": "personalcare", "name": "Personal Care", "icon": "self_care" }
]

View File

@@ -0,0 +1,34 @@
[
{
"id": "breakfast",
"label": "Need breakfast",
"query": "Need breakfast",
"bundleName": "Breakfast Essentials",
"storeId": "kumar",
"productIds": ["milk", "bread", "butter", "eggs", "coffee"]
},
{
"id": "baby",
"label": "Need baby products",
"query": "Need baby products",
"bundleName": "Baby Care Bundle",
"storeId": "kumar",
"productIds": ["diapers", "wipes", "lotion", "powder"]
},
{
"id": "biryani",
"label": "Ingredients for biryani",
"query": "Ingredients for biryani",
"bundleName": "Biryani Ingredients",
"storeId": "kumar",
"productIds": ["basmati", "chickenmasala", "onions", "ghee", "curd"]
},
{
"id": "cleaning",
"label": "Need cleaning supplies",
"query": "Need cleaning supplies",
"bundleName": "Cleaning Kit",
"storeId": "kumar",
"productIds": ["floorcleaner", "dishwash", "detergent", "toiletcleaner"]
}
]

View File

@@ -0,0 +1,23 @@
[
{ "id": "milk", "name": "Aavin Milk", "emoji": "🥛", "price": 40, "unit": "500 ml", "categoryId": "dairy" },
{ "id": "bread", "name": "Sandwich Bread", "emoji": "🍞", "price": 45, "unit": "400 g", "categoryId": "groceries" },
{ "id": "butter", "name": "Table Butter", "emoji": "🧈", "price": 90, "unit": "100 g", "categoryId": "dairy" },
{ "id": "eggs", "name": "Farm Eggs", "emoji": "🥚", "price": 80, "unit": "6 pcs", "categoryId": "dairy" },
{ "id": "coffee", "name": "Filter Coffee", "emoji": "☕", "price": 165, "unit": "500 g", "categoryId": "groceries" },
{ "id": "diapers", "name": "Baby Diapers", "emoji": "🧷", "price": 399, "unit": "Pack of 30", "categoryId": "personalcare" },
{ "id": "wipes", "name": "Baby Wipes", "emoji": "🧻", "price": 99, "unit": "72 pcs", "categoryId": "personalcare" },
{ "id": "lotion", "name": "Baby Lotion", "emoji": "🍼", "price": 149, "unit": "200 ml", "categoryId": "personalcare" },
{ "id": "powder", "name": "Baby Powder", "emoji": "🧴", "price": 85, "unit": "200 g", "categoryId": "personalcare" },
{ "id": "basmati", "name": "Basmati Rice", "emoji": "🍚", "price": 128, "unit": "1 kg", "categoryId": "groceries" },
{ "id": "chickenmasala", "name": "Biryani Masala", "emoji": "🌶️", "price": 60, "unit": "50 g", "categoryId": "groceries" },
{ "id": "onions", "name": "Onions", "emoji": "🧅", "price": 40, "unit": "1 kg", "categoryId": "groceries" },
{ "id": "ghee", "name": "Pure Ghee", "emoji": "🫕", "price": 320, "unit": "500 ml", "categoryId": "dairy" },
{ "id": "curd", "name": "Fresh Curd", "emoji": "🥣", "price": 40, "unit": "400 g", "categoryId": "dairy" },
{ "id": "floorcleaner", "name": "Floor Cleaner", "emoji": "🧽", "price": 99, "unit": "1 L", "categoryId": "household" },
{ "id": "dishwash", "name": "Dishwash Gel", "emoji": "🧼", "price": 55, "unit": "750 ml", "categoryId": "household" },
{ "id": "detergent", "name": "Detergent Powder", "emoji": "🧺", "price": 199, "unit": "2 kg", "categoryId": "household" },
{ "id": "toiletcleaner", "name": "Toilet Cleaner", "emoji": "🚽", "price": 89, "unit": "500 ml", "categoryId": "household" }
]

View File

@@ -0,0 +1,6 @@
[
{ "id": "kumar", "name": "Kumar Super Market", "icon": "storefront", "area": "R.S. Puram" },
{ "id": "freshmart", "name": "Fresh Mart", "icon": "storefront", "area": "Gandhipuram" },
{ "id": "murugan", "name": "Sri Murugan Store", "icon": "storefront", "area": "Saibaba Colony" },
{ "id": "aavin", "name": "Aavin Booth", "icon": "storefront", "area": "Peelamedu" }
]

View File

@@ -0,0 +1,142 @@
[
{
"id": "milk",
"domain": "shopping",
"emoji": "🥛",
"transcript": "Order two litres of milk",
"merchant": {
"name": "Kumar Super Market",
"area": "R.S. Puram",
"distance": "400 m",
"rating": 4.8,
"icon": "storefront"
},
"items": [
{ "emoji": "🥛", "name": "Aavin Milk", "qty": "2 × 1 L", "price": 124, "status": "Available" }
],
"total": 124,
"eta": "18 min",
"cta": "Confirm Order"
},
{
"id": "electrician",
"domain": "services",
"emoji": "🔧",
"transcript": "Book an electrician after 6 PM",
"merchant": {
"name": "Ravi Electricals",
"area": "Saibaba Colony",
"distance": "1.2 km",
"rating": 4.7,
"icon": "handyman"
},
"items": [
{ "emoji": "🔧", "name": "Electrician visit", "qty": "Today, 6:30 PM", "price": 249, "status": "Slot open" }
],
"total": 249,
"eta": "Arrives 6:30 PM",
"cta": "Confirm Booking"
},
{
"id": "biryani",
"domain": "shopping",
"emoji": "🍅",
"transcript": "Need ingredients for biryani",
"merchant": {
"name": "Kumar Super Market",
"area": "R.S. Puram",
"distance": "400 m",
"rating": 4.8,
"icon": "storefront"
},
"items": [
{ "emoji": "🍚", "name": "Basmati Rice", "qty": "1 kg", "price": 128, "status": "Available" },
{ "emoji": "🌶️", "name": "Biryani Masala", "qty": "50 g", "price": 60, "status": "Available" },
{ "emoji": "🧅", "name": "Onions", "qty": "1 kg", "price": 40, "status": "Available" },
{ "emoji": "🥣", "name": "Fresh Curd", "qty": "400 g", "price": 40, "status": "Available" }
],
"total": 268,
"eta": "22 min",
"cta": "Confirm Order"
},
{
"id": "medicine",
"domain": "healthcare",
"emoji": "💊",
"transcript": "Order medicine for headache",
"merchant": {
"name": "Sri Venkateshwara Pharmacy",
"area": "Gandhipuram",
"distance": "600 m",
"rating": 4.9,
"icon": "local_pharmacy"
},
"items": [
{ "emoji": "💊", "name": "Paracetamol 500 mg", "qty": "1 strip", "price": 32, "status": "Available" },
{ "emoji": "🧴", "name": "Pain Relief Balm", "qty": "10 g", "price": 65, "status": "Available" }
],
"total": 97,
"eta": "15 min",
"cta": "Confirm Order"
},
{
"id": "breakfast",
"domain": "shopping",
"emoji": "🥣",
"transcript": "Need breakfast for four",
"merchant": {
"name": "Fresh Mart",
"area": "Gandhipuram",
"distance": "850 m",
"rating": 4.6,
"icon": "storefront"
},
"items": [
{ "emoji": "🍞", "name": "Sandwich Bread", "qty": "400 g", "price": 45, "status": "Available" },
{ "emoji": "🥚", "name": "Farm Eggs", "qty": "6 pcs", "price": 80, "status": "Available" },
{ "emoji": "🧈", "name": "Table Butter", "qty": "100 g", "price": 90, "status": "Available" },
{ "emoji": "☕", "name": "Filter Coffee", "qty": "500 g", "price": 165, "status": "Available" }
],
"total": 380,
"eta": "20 min",
"cta": "Confirm Order"
},
{
"id": "plumber",
"domain": "services",
"emoji": "🚿",
"transcript": "Book a plumber for tomorrow",
"merchant": {
"name": "SS Plumbing Works",
"area": "Peelamedu",
"distance": "1.6 km",
"rating": 4.5,
"icon": "plumbing"
},
"items": [
{ "emoji": "🚿", "name": "Plumber visit", "qty": "Tomorrow, 10 AM", "price": 299, "status": "Slot open" }
],
"total": 299,
"eta": "Arrives 10:00 AM",
"cta": "Confirm Booking"
},
{
"id": "acservice",
"domain": "services",
"emoji": "❄️",
"transcript": "Book AC service this weekend",
"merchant": {
"name": "CoolCare Services",
"area": "R.S. Puram",
"distance": "900 m",
"rating": 4.8,
"icon": "ac_unit"
},
"items": [
{ "emoji": "❄️", "name": "AC deep service", "qty": "Saturday, 11 AM", "price": 599, "status": "Slot open" }
],
"total": 599,
"eta": "Arrives 11:00 AM",
"cta": "Confirm Booking"
}
]

View File

@@ -0,0 +1,189 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
// The eight beats of a merchant's life on Nearle. The visitor watches the whole
// arc — join → digitize → sell → grow — play out once, then can replay/drive it.
export type MerchantStageId =
| "join"
| "digitize"
| "products"
| "browse"
| "orders"
| "packed"
| "rider"
| "grows";
export type MerchantStep = {
id: MerchantStageId;
num: string;
label: string; // short label for the progress rail
icon: string; // Material Symbol for the rail node
title: string;
desc: string;
};
export const MERCHANT_STEPS: MerchantStep[] = [
{
id: "join",
num: "01",
label: "Join",
icon: "handshake",
title: "Merchant Joins Nearle",
desc: "Your neighbourhood shop signs up in minutes — no tech skills needed.",
},
{
id: "digitize",
num: "02",
label: "Digitize",
icon: "qr_code_2",
title: "Store Digitized",
desc: "A quick QR scan turns your shelves into a searchable digital storefront.",
},
{
id: "products",
num: "03",
label: "Products",
icon: "inventory_2",
title: "Products Added",
desc: "AI reads a shelf photo and builds your live, shoppable catalogue.",
},
{
id: "browse",
num: "04",
label: "Browse",
icon: "search",
title: "Customers Browse",
desc: "Nearby shoppers discover and search your store in natural language.",
},
{
id: "orders",
num: "05",
label: "Orders",
icon: "receipt_long",
title: "Orders Received",
desc: "Orders land directly with you — an instant alert for every sale.",
},
{
id: "packed",
num: "06",
label: "Packed",
icon: "package_2",
title: "Products Packed",
desc: "You pack the order yourself, staying at the centre of every sale.",
},
{
id: "rider",
num: "07",
label: "Delivery",
icon: "two_wheeler",
title: "Nearle Rider Delivers",
desc: "A Nearle rider picks up and delivers to your customers door.",
},
{
id: "grows",
num: "08",
label: "Growth",
icon: "trending_up",
title: "Business Grows",
desc: "Demand insights and repeat orders compound into steady local growth.",
},
];
const AUTOPLAY_MS = 3200; // each stage stays readable for ~3.2s
export type MerchantJourney = {
steps: MerchantStep[];
index: number;
step: MerchantStep;
started: boolean;
autoplaying: boolean;
atEnd: boolean;
reduced: boolean;
start: () => void;
goTo: (i: number) => void;
next: () => void;
prev: () => void;
replay: () => void;
};
// Autoplays through the stages exactly once on scroll-into-view, then rests on
// the final stage. Any manual input (rail node, arrow, swipe) cancels autoplay
// and hands control to the visitor. Reduced motion → manual from the start.
export function useMerchantJourney(): MerchantJourney {
const reduced = useReducedMotion() ?? false;
const [started, setStarted] = useState(false);
const [index, setIndex] = useState(0);
const [autoplaying, setAutoplaying] = useState(false);
const last = MERCHANT_STEPS.length - 1;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
}, []);
useEffect(() => clear, [clear]);
// Auto-advance one stage at a time; simply stop scheduling (don't loop) at the
// end so the journey rests on the final stage. No synchronous setState here.
useEffect(() => {
if (!started || reduced || !autoplaying || index >= last) return;
timer.current = setTimeout(() => setIndex((i) => Math.min(i + 1, last)), AUTOPLAY_MS);
return clear;
}, [started, reduced, autoplaying, index, last, clear]);
const start = useCallback(() => {
setStarted((was) => {
if (!was && !reduced) setAutoplaying(true);
return true;
});
}, [reduced]);
const goTo = useCallback(
(i: number) => {
clear();
setAutoplaying(false);
setStarted(true);
setIndex(Math.max(0, Math.min(i, last)));
},
[clear, last]
);
const next = useCallback(() => {
clear();
setAutoplaying(false);
setStarted(true);
setIndex((i) => Math.min(i + 1, last));
}, [clear, last]);
const prev = useCallback(() => {
clear();
setAutoplaying(false);
setStarted(true);
setIndex((i) => Math.max(i - 1, 0));
}, [clear]);
const replay = useCallback(() => {
clear();
setIndex(0);
setStarted(true);
setAutoplaying(!reduced);
}, [clear, reduced]);
return {
steps: MERCHANT_STEPS,
index,
step: MERCHANT_STEPS[index],
started,
autoplaying,
atEnd: index >= last,
reduced,
start,
goTo,
next,
prev,
replay,
};
}

View File

@@ -0,0 +1,244 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
import {
getIntents,
getMyStores,
getRecommendation,
getStore,
} from "@/lib/experience/search";
import type { Intent, Recommendation, Store } from "@/types/experience";
// The seven-stage story a visitor watches (and can drive) to understand Nearle:
// choose a trusted store → walk in → search by intent → AI resolves it →
// see in-store recommendations → cart → merchant packs & rider delivers.
export type JourneyStage =
| "choose-store"
| "enter-store"
| "search"
| "thinking"
| "results"
| "cart"
| "fulfilment"
| "done";
export const STAGE_ORDER: JourneyStage[] = [
"choose-store",
"enter-store",
"search",
"thinking",
"results",
"cart",
"fulfilment",
"done",
];
export const AI_STEPS = [
"Understanding your shopping intent…",
"Finding matching products…",
"Checking availability…",
"Preparing recommendations…",
] as const;
export const FULFILMENT_STEPS = [
{ label: "Order sent", icon: "send" },
{ label: "Merchant receives order", icon: "storefront" },
{ label: "Products packed", icon: "inventory_2" },
{ label: "Nearle rider assigned", icon: "two_wheeler" },
{ label: "Delivered", icon: "check_circle" },
] as const;
// Pacing for the auto-play. Tuned to feel like a real person using the app —
// deliberate, with a beat of stillness between stages — not a slideshow.
// Full run lands around 1822s (varies with the typed query length).
const TIMING = {
chooseStoreHold: 950, // hold on the highlighted store
enterStoreHold: 1400, // fade into the store, then settle
typeCharMs: 95, // per-character typing cadence
afterTypeHold: 700, // pause after typing before AI thinks
thinkStepMs: 1000, // gap between each AI thought (~ChatGPT pace)
thinkFinalHold: 1200, // dwell on the last thought before results
resultsHold: 2900, // let products fade in one by one, then hold
cartHold: 2600, // read the basket + total before checkout
fulfilStepMs: 1050, // each merchant/rider beat stays readable
} as const;
const STORES = getMyStores();
const INTENTS = getIntents();
export type StoreJourney = {
stage: JourneyStage;
stores: Store[];
intents: Intent[];
store: Store;
intent: Intent;
recommendation: Recommendation | null;
typed: string;
thinkingStep: number;
fulfilStep: number;
reduced: boolean;
start: () => void;
selectStore: (id: string) => void;
pickIntent: (id: string) => void;
replay: () => void;
};
// One shared state machine drives the whole journey. Auto-advances via timers
// once started (on scroll-into-view), but store cards and intent chips can
// interrupt it at any point. Under reduced-motion it settles on the end state.
export function useStoreJourney(): StoreJourney {
const reduce = useReducedMotion() ?? false;
const [started, setStarted] = useState(false);
const [stage, setStage] = useState<JourneyStage>("choose-store");
const [storeId, setStoreId] = useState(STORES[0]?.id ?? "");
const [intentId, setIntentId] = useState(INTENTS[0]?.id ?? "");
const [typed, setTyped] = useState("");
const [thinkingStep, setThinkingStep] = useState(0);
const [fulfilStep, setFulfilStep] = useState(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const intervals = useRef<ReturnType<typeof setInterval>[]>([]);
const clearAll = useCallback(() => {
timers.current.forEach(clearTimeout);
intervals.current.forEach(clearInterval);
timers.current = [];
intervals.current = [];
}, []);
useEffect(() => clearAll, [clearAll]);
const store = useMemo(
() => getStore(storeId) ?? STORES[0],
[storeId]
);
const intent = useMemo(
() => INTENTS.find((i) => i.id === intentId) ?? INTENTS[0],
[intentId]
);
const recommendation = useMemo<Recommendation | null>(() => {
const base = getRecommendation(intentId);
// Honour the store the visitor actually picked for the display.
return base ? { ...base, store } : null;
}, [intentId, store]);
// ── Auto-play orchestration (skipped under reduced motion) ──
useEffect(() => {
if (!started || reduce) return;
clearAll();
const push = (t: ReturnType<typeof setTimeout>) => timers.current.push(t);
switch (stage) {
case "choose-store":
// Let the highlighted store register before walking in.
push(setTimeout(() => setStage("enter-store"), TIMING.chooseStoreHold));
break;
case "enter-store":
// Smooth fade into the store, then a beat to take it in.
push(setTimeout(() => setStage("search"), TIMING.enterStoreHold));
break;
case "search": {
setTyped("");
const q = intent.query;
let i = 0;
const iv = setInterval(() => {
i += 1;
setTyped(q.slice(0, i));
if (i >= q.length) {
clearInterval(iv);
// Pause after typing before the AI starts thinking.
push(setTimeout(() => setStage("thinking"), TIMING.afterTypeHold));
}
}, TIMING.typeCharMs);
intervals.current.push(iv);
break;
}
case "thinking":
// Reveal each thought one at a time, ChatGPT-style (~4s total).
setThinkingStep(0);
for (let s = 1; s < AI_STEPS.length; s += 1) {
push(setTimeout(() => setThinkingStep(s), TIMING.thinkStepMs * s));
}
push(
setTimeout(
() => setStage("results"),
TIMING.thinkStepMs * (AI_STEPS.length - 1) + TIMING.thinkFinalHold
)
);
break;
case "results":
// Products fade in one by one (staggered in the canvas), then hold.
push(setTimeout(() => setStage("cart"), TIMING.resultsHold));
break;
case "cart":
push(setTimeout(() => setStage("fulfilment"), TIMING.cartHold));
break;
case "fulfilment":
// Each fulfilment beat stays visible long enough to read.
setFulfilStep(0);
for (let s = 1; s < FULFILMENT_STEPS.length; s += 1) {
push(setTimeout(() => setFulfilStep(s), TIMING.fulfilStepMs * s));
}
push(
setTimeout(
() => setStage("done"),
TIMING.fulfilStepMs * FULFILMENT_STEPS.length
)
);
break;
default:
break;
}
return clearAll;
}, [stage, started, reduce, intentId, intent.query, clearAll]);
// ── Reduced motion: settle directly on the completed end state ──
useEffect(() => {
if (!started || !reduce) return;
setTyped(intent.query);
setThinkingStep(AI_STEPS.length - 1);
setFulfilStep(FULFILMENT_STEPS.length - 1);
setStage("done");
}, [started, reduce, intentId, intent.query]);
const start = useCallback(() => setStarted(true), []);
const selectStore = useCallback((id: string) => {
setStarted(true);
setStoreId(id);
setStage((s) => (s === "choose-store" ? "enter-store" : s));
}, []);
const pickIntent = useCallback((id: string) => {
setStarted(true);
setIntentId(id);
setStage("search");
}, []);
const replay = useCallback(() => {
clearAll();
setTyped("");
setThinkingStep(0);
setFulfilStep(0);
setStage("choose-store");
setStarted(true);
}, [clearAll]);
return {
stage,
stores: STORES,
intents: INTENTS,
store,
intent,
recommendation,
typed,
thinkingStep,
fulfilStep,
reduced: reduce,
start,
selectStore,
pickIntent,
replay,
};
}

View File

@@ -0,0 +1,214 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
import { getVoiceRequests } from "@/lib/experience/voice";
import type { VoiceRequest } from "@/types/experience";
// The story the hero plays out, end to end:
// open Nearle → speak → AI understands → AI finds a nearby merchant →
// order created → merchant accepts → packed → rider → delivered.
//
// Note there is no store-selection step. The merchant is *discovered by the AI*
// after it understands the request — that is the whole point of voice commerce.
export type VoiceStage =
| "home" // iOS home screen, Nearle icon pulsing
| "tap" // touch indicator presses the icon
| "launch" // icon expands into the app
| "listening" // mic open, waveform idling
| "speaking" // transcript building word by word
| "thinking" // AI reasoning steps
| "merchant" // nearby merchant resolved
| "order" // draft order + confirm
| "confirmed" // order placed
| "fulfilment" // merchant → packing → rider
| "delivered"; // handed over
export const AI_STEPS = [
"Understanding request…",
"Searching nearby merchants…",
"Checking availability…",
"Comparing options…",
"Creating order…",
] as const;
export const FULFILMENT_STEPS = [
{ label: "Order confirmed", icon: "receipt_long" },
{ label: "Merchant accepts", icon: "storefront" },
{ label: "Packing your order", icon: "inventory_2" },
{ label: "Nearle rider assigned", icon: "two_wheeler" },
{ label: "Delivered", icon: "check_circle" },
] as const;
// A booked service is fulfilled by a person turning up, not a rider dropping a
// bag — so it gets its own track, same five beats and the same pacing.
export const SERVICE_FULFILMENT_STEPS = [
{ label: "Booking confirmed", icon: "receipt_long" },
{ label: "Professional accepts", icon: "engineering" },
{ label: "Slot locked in", icon: "event_available" },
{ label: "On the way to you", icon: "two_wheeler" },
{ label: "Job completed", icon: "check_circle" },
] as const;
// Pacing. Tuned so a visitor reads each beat without waiting on it — the whole
// story lands in one ~24s pass, and the first 8s already says "speak, AI
// understands, a nearby shop fulfils it".
const TIMING = {
home: 1500, // let the home screen register, icon pulses
tap: 450, // press down on the Nearle icon
launch: 700, // iOS-style expand into the app
listening: 1200, // mic opens, waveform idles
wordMs: 190, // per-word cadence of the transcript
afterSpeak: 620, // beat between the last word and the AI starting
thinkStepMs: 900, // gap between reasoning steps
thinkFinalHold: 700, // dwell on the last step
merchant: 1900, // read the merchant card
order: 2600, // read the items + total, then confirm auto-taps
confirmed: 1100, // success bloom
fulfilStepMs: 1050, // each fulfilment beat stays readable
delivered: 1900, // hold on delivered before the next request
} as const;
const REQUESTS = getVoiceRequests();
export type VoiceJourney = {
stage: VoiceStage;
request: VoiceRequest;
requests: VoiceRequest[];
/** Words of the transcript revealed so far. */
spokenWords: number;
thinkingStep: number;
fulfilStep: number;
reduced: boolean;
start: () => void;
stop: () => void;
};
// One shared state machine drives the phone screen. Timers only run while the
// caller says it is visible (start/stop from an in-view observer), so the loop
// costs nothing when the hero is scrolled past.
export function useVoiceJourney(): VoiceJourney {
const reduce = useReducedMotion() ?? false;
const [running, setRunning] = useState(false);
const [stage, setStage] = useState<VoiceStage>("home");
const [index, setIndex] = useState(0);
// Tagged with the request it belongs to, so a new request starts from zero
// words without needing to reset anything.
const [spoken, setSpoken] = useState({ at: -1, n: 0 });
const [think, setThink] = useState({ at: -1, n: 0 });
const [fulfil, setFulfil] = useState({ at: -1, n: 0 });
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const intervals = useRef<ReturnType<typeof setInterval>[]>([]);
const clearAll = useCallback(() => {
timers.current.forEach(clearTimeout);
intervals.current.forEach(clearInterval);
timers.current = [];
intervals.current = [];
}, []);
useEffect(() => clearAll, [clearAll]);
const request = useMemo(() => REQUESTS[index % REQUESTS.length], [index]);
const wordCount = useMemo(() => request.transcript.split(" ").length, [request]);
const spokenWords = spoken.at === index ? spoken.n : 0;
const thinkingStep = think.at === index ? think.n : 0;
const fulfilStep = fulfil.at === index ? fulfil.n : 0;
// ── Auto-play orchestration (skipped under reduced motion) ──
useEffect(() => {
if (!running || reduce) return;
clearAll();
const push = (t: ReturnType<typeof setTimeout>) => timers.current.push(t);
const next = (s: VoiceStage, delay: number) => push(setTimeout(() => setStage(s), delay));
switch (stage) {
case "home":
next("tap", TIMING.home);
break;
case "tap":
next("launch", TIMING.tap);
break;
case "launch":
next("listening", TIMING.launch);
break;
case "listening":
next("speaking", TIMING.listening);
break;
case "speaking": {
// Reveal the request one word at a time, the way live dictation lands.
let w = 0;
const iv = setInterval(() => {
w += 1;
setSpoken({ at: index, n: w });
if (w >= wordCount) {
clearInterval(iv);
next("thinking", TIMING.afterSpeak);
}
}, TIMING.wordMs);
intervals.current.push(iv);
break;
}
case "thinking":
for (let s = 1; s < AI_STEPS.length; s += 1) {
push(setTimeout(() => setThink({ at: index, n: s }), TIMING.thinkStepMs * s));
}
next(
"merchant",
TIMING.thinkStepMs * (AI_STEPS.length - 1) + TIMING.thinkFinalHold
);
break;
case "merchant":
next("order", TIMING.merchant);
break;
case "order":
next("confirmed", TIMING.order);
break;
case "confirmed":
next("fulfilment", TIMING.confirmed);
break;
case "fulfilment":
for (let s = 1; s < FULFILMENT_STEPS.length; s += 1) {
push(setTimeout(() => setFulfil({ at: index, n: s }), TIMING.fulfilStepMs * s));
}
next("delivered", TIMING.fulfilStepMs * FULFILMENT_STEPS.length);
break;
case "delivered":
// Loop back to the mic with the *next* request — never the same one twice.
push(
setTimeout(() => {
setIndex((i) => (i + 1) % REQUESTS.length);
setStage("listening");
}, TIMING.delivered)
);
break;
default:
break;
}
return clearAll;
}, [stage, running, reduce, wordCount, index, clearAll]);
// ── Reduced motion: report the completed end state directly ──
// Derived rather than pushed through setState, so nothing ever animates or
// re-renders its way there — the demo is simply already finished.
const settled = reduce && running;
const start = useCallback(() => setRunning(true), []);
const stop = useCallback(() => {
setRunning(false);
clearAll();
}, [clearAll]);
return {
stage: settled ? "delivered" : stage,
request,
requests: REQUESTS,
spokenWords: settled ? wordCount : spokenWords,
thinkingStep: settled ? AI_STEPS.length - 1 : thinkingStep,
fulfilStep: settled ? FULFILMENT_STEPS.length - 1 : fulfilStep,
reduced: reduce,
start,
stop,
};
}

View File

@@ -0,0 +1,59 @@
// The ONLY module the store-journey UI imports for data.
//
// Everything here is powered by local dummy JSON today. To go live, swap each
// function body for a real API call and keep these exported signatures
// unchanged — the UI never needs to change.
import storesData from "@/data/experience/stores.json";
import categoriesData from "@/data/experience/categories.json";
import productsData from "@/data/experience/products.json";
import intentsData from "@/data/experience/intents.json";
import type { Category, Intent, Product, Recommendation, Store } from "@/types/experience";
const stores = storesData as Store[];
const categories = categoriesData as Category[];
const products = productsData as Product[];
const intents = intentsData as Intent[];
/** The stores a customer has already added — their "My Stores" list. */
export function getMyStores(): Store[] {
return stores;
}
export function getStore(id: string): Store | undefined {
return stores.find((s) => s.id === id);
}
/** The in-store shelves shown after entering a store. */
export function getCategories(): Category[] {
return categories;
}
/** The natural-language shopping intents offered as search chips. */
export function getIntents(): Intent[] {
return intents;
}
/**
* Resolve a shopping intent into a store-scoped bundle — the products Nearle AI
* recommends, joined with their store, plus the cart totals.
*/
export function getRecommendation(intentId: string): Recommendation | null {
const intent = intents.find((i) => i.id === intentId);
if (!intent) return null;
const store = getStore(intent.storeId);
if (!store) return null;
const items = intent.productIds
.map((pid) => products.find((p) => p.id === pid))
.filter((p): p is Product => Boolean(p));
return {
intent,
store,
products: items,
itemCount: items.length,
total: items.reduce((sum, p) => sum + p.price, 0),
};
}

View File

@@ -0,0 +1,19 @@
// The ONLY module the hero voice demo imports for data.
//
// Same contract as ./search.ts: everything is local dummy JSON today. To go
// live, swap the function bodies for real API calls and keep these exported
// signatures unchanged — the hero never needs to change.
import voiceData from "@/data/experience/voice-requests.json";
import type { VoiceRequest } from "@/types/experience";
const requests = voiceData as VoiceRequest[];
/**
* The spoken requests the hero demo cycles through — deliberately spread across
* shopping, services and healthcare so the hero shows Nearle covers products
* *and* local services, not only grocery.
*/
export function getVoiceRequests(): VoiceRequest[] {
return requests;
}

87
src/types/experience.ts Normal file
View File

@@ -0,0 +1,87 @@
// Shared types for the "Digital Neighbourhood Store" homepage journey.
// These mirror the shape of the dummy JSON in src/data/experience so the files
// can later be replaced by real API responses without touching the UI.
// A store the customer has already added (via QR onboarding) — their "My Stores".
export type Store = {
id: string;
name: string;
icon: string; // Material Symbol name
area: string;
};
// A shelf/category inside a store.
export type Category = {
id: string;
name: string;
icon: string;
};
// A single product on a store's digital shelf.
export type Product = {
id: string;
name: string;
emoji: string;
price: number;
unit: string;
categoryId: string;
};
// A natural-language shopping intent ("Need breakfast") mapped to a bundle of
// products inside one store — what Nearle AI resolves a search into.
export type Intent = {
id: string;
label: string; // "Need breakfast"
query: string; // what gets typed into the search bar
bundleName: string; // "Breakfast Essentials"
storeId: string;
productIds: string[];
};
// An intent resolved against its store — the shape the journey UI renders.
export type Recommendation = {
intent: Intent;
store: Store;
products: Product[];
itemCount: number;
total: number;
};
// ─── Voice commerce (homepage hero demo) ───
// What Nearle AI resolves a spoken request into. Unlike the store journey above,
// the merchant is discovered *by the AI* after it understands the request —
// the customer never picks a store first.
// Nearle covers products and local services, not only grocery shopping.
export type VoiceDomain = "shopping" | "services" | "healthcare";
// The nearby merchant the AI surfaces once it understands the request.
export type VoiceMerchant = {
name: string;
area: string;
distance: string; // "400 m"
rating: number;
icon: string; // Material Symbol name
};
// A line on the draft order — a product, or a service slot ("Today, 6:30 PM").
export type VoiceItem = {
emoji: string;
name: string;
qty: string;
price: number;
status: string; // "Available" / "Slot open"
};
// One spoken request, end to end: what was said, who fulfils it, what it costs.
export type VoiceRequest = {
id: string;
domain: VoiceDomain;
emoji: string;
transcript: string; // exactly what the customer says
merchant: VoiceMerchant;
items: VoiceItem[];
total: number;
eta: string;
cta: string; // "Confirm Order" / "Confirm Booking"
};