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