arrenge the home page order
This commit is contained in:
@@ -573,6 +573,48 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── AI Commerce ambient decoration ─── */
|
||||
/* Very slow, very low-contrast drift. Meant to be felt, never watched.
|
||||
Transform-only so it stays on the compositor. */
|
||||
@keyframes ai-orb-drift-a {
|
||||
0%, 100% {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(28px, -22px, 0) scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ai-orb-drift-b {
|
||||
0%, 100% {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(-34px, 18px, 0) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ai-mote-float {
|
||||
0%, 100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(6px, -18px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-orb-a {
|
||||
animation: ai-orb-drift-a 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-orb-b {
|
||||
animation: ai-orb-drift-b 27s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-mote {
|
||||
animation: ai-mote-float 14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Footer Layout & Sizing System ─── */
|
||||
.footer-container {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
|
||||
import { MerchantsHero } from "@/components/heroes/MerchantsHero";
|
||||
import { HowItWorks } from "@/components/home/HowItWorks";
|
||||
import { FAQAccordion } from "@/components/ui/FAQAccordion";
|
||||
import { AnimatedSection } from "@/components/animations/AnimatedSection";
|
||||
import { AnimatedGrid, AnimatedCard } from "@/components/animations/AnimatedGrid";
|
||||
@@ -159,6 +160,7 @@ export default function MerchantsPage() {
|
||||
return (
|
||||
<PremiumPageShell>
|
||||
<MerchantsHero />
|
||||
<HowItWorks />
|
||||
|
||||
{/* ─── Merchant Benefits Grid ─── */}
|
||||
<section className="py-10 md:py-14 xl:py-12 bg-white border-b border-[#6330D6]/10">
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
|
||||
import { Hero } from "@/components/home/Hero";
|
||||
import { WhyNearleDifferent } from "@/components/home/WhyNearleDifferent";
|
||||
import { AICommerceIntro } from "@/components/home/AICommerceIntro";
|
||||
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 = {
|
||||
@@ -29,15 +29,15 @@ export default function HomePage() {
|
||||
return (
|
||||
<PremiumPageShell>
|
||||
<Hero />
|
||||
<WhyAICommerceWins />
|
||||
<AICommerceIntro />
|
||||
<VoiceCommerceExperience />
|
||||
<StoreJourney />
|
||||
<ServiceCategories />
|
||||
<WhyNearle />
|
||||
<WhyNearleDifferent />
|
||||
<HowItWorks />
|
||||
<HyperlocalFeatures />
|
||||
<ServiceCategories />
|
||||
<BusinessSection />
|
||||
<WhyAICommerceWins />
|
||||
<AICommerceTeaser />
|
||||
<StoreQRSection />
|
||||
</PremiumPageShell>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PremiumPageShell } from "@/components/common/PremiumPageShell";
|
||||
import { PlatformHero } from "@/components/platform/PlatformHero";
|
||||
import { CorePrinciples } from "@/components/home/CorePrinciples";
|
||||
import { PlatformArchitecture } from "@/components/platform/PlatformArchitecture";
|
||||
import { ProductFeatureGrid } from "@/components/platform/ProductFeatureGrid";
|
||||
import { ProductLLMWorkflow } from "@/components/platform/ProductLLMWorkflow";
|
||||
@@ -54,7 +55,8 @@ export default function PlatformPage() {
|
||||
body="Join the neighbourhood stores already running on MerchantOS — with an AI catalog by ProductLLM and a growing base of local shoppers on ConsumerOS."
|
||||
primary={{ href: "/businesses", icon: "storefront", label: "For Business" }}
|
||||
secondary={{ href: "/why-nearle", icon: "auto_awesome", label: "Why AI Commerce" }}
|
||||
/>
|
||||
/>
|
||||
<CorePrinciples />
|
||||
</PremiumPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ import { PullQuote } from "@/components/ui/PullQuote";
|
||||
import { AICommerceCTA } from "@/components/why-nearle/AICommerceCTA";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "What is AI Commerce?",
|
||||
title: "What is AI Commerce",
|
||||
description:
|
||||
"AI Commerce starts where quick commerce stops. Nearle digitizes neighbourhood stores with AI, digital storefronts, and ordering — instead of replacing them. Learn how AI Commerce works.",
|
||||
openGraph: {
|
||||
title: "What is AI Commerce? | Nearle",
|
||||
title: "What is AI Commerce | Nearle",
|
||||
description:
|
||||
"Neighbourhood stores already have trust, customers, and location. Nearle adds AI, a digital storefront, inventory, and ordering. This is AI Commerce.",
|
||||
url: "https://nearle.in/why-nearle",
|
||||
|
||||
243
src/components/home/AICommerceIntro.tsx
Normal file
243
src/components/home/AICommerceIntro.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { Spotlight } from "@/components/ui/Spotlight";
|
||||
import { fadeUp, staggerContainer, staggerCards, cardReveal, viewport } from "@/lib/animations";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
step: "01",
|
||||
title: "Natural Intent",
|
||||
desc: "Tell Nearle what you need in plain Tamil or English. AI understands local store products, brands, and quantities without manual searching.",
|
||||
icon: "psychology",
|
||||
badge: "Voice & Text AI",
|
||||
iconBg: "bg-primary/10 text-primary border-primary/20",
|
||||
badgeStyle: "bg-primary/10 text-primary border-primary/20",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "Live Local Discovery",
|
||||
desc: "AI instantly checks live stock and pricing at verified neighbourhood stores right around your street to find exact matches.",
|
||||
icon: "storefront",
|
||||
badge: "Real-Time Stock",
|
||||
iconBg: "bg-secondary/10 text-secondary border-secondary/20",
|
||||
badgeStyle: "bg-secondary/10 text-secondary border-secondary/20",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Instant Fulfillment",
|
||||
desc: "Confirms your order, alerts the local shopkeeper to pack, and dispatches a Nearle rider for fast doorstep delivery — all in 1 chat.",
|
||||
icon: "bolt",
|
||||
badge: "1-Tap Fulfillment",
|
||||
iconBg: "bg-[#7C3AED]/10 text-[#7C3AED] border-[#7C3AED]/20",
|
||||
badgeStyle: "bg-[#7C3AED]/10 text-[#7C3AED] border-[#7C3AED]/20",
|
||||
},
|
||||
];
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
{
|
||||
title: "Zero App Switching",
|
||||
sub: "Everything in 1 conversation",
|
||||
icon: "forum",
|
||||
iconColor: "bg-primary/10 text-primary",
|
||||
},
|
||||
{
|
||||
title: "Verified Merchants",
|
||||
sub: "Trusted neighbourhood shops",
|
||||
icon: "verified_user",
|
||||
iconColor: "bg-secondary/10 text-secondary",
|
||||
},
|
||||
{
|
||||
title: "Live Inventory",
|
||||
sub: "Real-time store stock",
|
||||
icon: "inventory_2",
|
||||
iconColor: "bg-blue-600/10 text-blue-600",
|
||||
},
|
||||
{
|
||||
title: "Fast Rider Delivery",
|
||||
sub: "Direct doorstep fulfillment",
|
||||
icon: "local_shipping",
|
||||
iconColor: "bg-[#7C3AED]/10 text-[#7C3AED]",
|
||||
},
|
||||
];
|
||||
|
||||
export function AICommerceIntro() {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section
|
||||
id="ai-commerce"
|
||||
aria-labelledby="ai-commerce-title"
|
||||
className="section section-soft w-full overflow-hidden border-y border-[#6330D6]/10"
|
||||
>
|
||||
<div className="page-container relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mx-auto max-w-[780px] text-center mb-12 md:mb-16"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="inline-flex items-center rounded-full bg-[#6330D6]/[0.08] px-3.5 py-1.5 text-xs font-black uppercase tracking-widest text-[#6330D6]"
|
||||
>
|
||||
Nearle AI Engine
|
||||
</motion.span>
|
||||
|
||||
<motion.h2
|
||||
id="ai-commerce-title"
|
||||
variants={fadeUp}
|
||||
className="mt-4 font-display font-black tracking-tight text-[#16001D] text-4xl md:text-5xl lg:text-[54px] leading-tight"
|
||||
>
|
||||
Neighbourhood Shopping, <span className="text-[#7C3AED]">Reimagined by AI</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-4 max-w-[640px] text-base md:text-lg leading-relaxed text-[#5E5866]"
|
||||
>
|
||||
Instead of searching across multiple apps and comparing items manually, simply tell Nearle what you need. AI handles discovery, stock checking, and ordering — all in one conversation.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Feature Showcase Banner */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mx-auto max-w-[1100px] mb-14"
|
||||
>
|
||||
<Spotlight
|
||||
className="overflow-hidden rounded-[2.5rem] border border-[#6330D6]/15 bg-[linear-gradient(135deg,#ffffff_0%,#fbf7ff_55%,#f1e8fb_100%)] p-8 md:p-12 shadow-[0_16px_44px_-16px_rgba(99,48,214,0.2)]"
|
||||
color="rgba(168,85,247,0.14)"
|
||||
size={500}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
|
||||
<div className="lg:col-span-7">
|
||||
<div className="inline-flex items-center gap-2 rounded-xl bg-[#7C3AED]/10 px-3 py-1 text-xs font-black text-[#7C3AED] mb-4">
|
||||
<MaterialIcon icon="auto_awesome" size={16} />
|
||||
<span>Conversational Commerce</span>
|
||||
</div>
|
||||
<h3 className="font-display text-2xl md:text-3xl font-black text-[#16001D] leading-tight mb-4">
|
||||
Talk to Nearle AI like you speak to your local shopkeeper
|
||||
</h3>
|
||||
<p className="text-base md:text-lg text-[#5E5866] leading-relaxed">
|
||||
Nearle connects natural consumer intent directly to verified local merchant inventory. You ask, AI discovers nearby availability, confirms with the merchant, and dispatches delivery.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3.5 rounded-2xl border border-[#6330D6]/10 bg-white/90 p-4 shadow-sm">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<MaterialIcon icon="mic" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-black text-primary uppercase tracking-wider">Step 1 Intent</p>
|
||||
<p className="text-sm font-bold text-[#16001D]">Natural Voice & Text Query</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3.5 rounded-2xl border border-[#6330D6]/10 bg-white/90 p-4 shadow-sm">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#EC4899]/10 text-[#EC4899]">
|
||||
<MaterialIcon icon="travel_explore" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-black text-[#EC4899] uppercase tracking-wider">Step 2 Match</p>
|
||||
<p className="text-sm font-bold text-[#16001D]">Live Local Shop Scan</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3.5 rounded-2xl border border-[#6330D6]/10 bg-white/90 p-4 shadow-sm">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#10B981]/10 text-[#10B981]">
|
||||
<MaterialIcon icon="local_shipping" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-black text-[#10B981] uppercase tracking-wider">Step 3 Deliver</p>
|
||||
<p className="text-sm font-bold text-[#16001D]">Shopkeeper Pack & Rider Delivery</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spotlight>
|
||||
</motion.div>
|
||||
|
||||
{/* 3 Step Cards Grid */}
|
||||
<motion.div
|
||||
variants={staggerCards}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 mx-auto max-w-[1100px]"
|
||||
>
|
||||
{STEPS.map((step) => (
|
||||
<motion.div
|
||||
key={step.title}
|
||||
variants={cardReveal}
|
||||
whileHover={reduce ? undefined : { y: -6, scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 22 }}
|
||||
className="flex flex-col justify-between rounded-[2rem] border border-[#16001D]/8 bg-white p-7 md:p-8 shadow-[0_10px_30px_rgba(22,0,29,0.05)] transition-all duration-300 hover:border-[#7C3AED]/30 hover:shadow-[0_20px_40px_-15px_rgba(99,48,214,0.15)] h-full"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${step.iconBg}`}>
|
||||
<MaterialIcon icon={step.icon} size={24} />
|
||||
</span>
|
||||
<span className="font-display text-xs font-black tracking-widest text-[#7C3AED] uppercase bg-[#7C3AED]/5 border border-[#7C3AED]/15 px-3 py-1 rounded-full">
|
||||
{step.step}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`inline-block rounded-md border px-2.5 py-1 text-xs font-black mb-3 ${step.badgeStyle}`}>
|
||||
{step.badge}
|
||||
</div>
|
||||
|
||||
<h3 className="font-display text-2xl font-black tracking-tight text-[#16001D] mb-3">
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm md:text-base text-[#5E5866] leading-relaxed">
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 4 Feature Highlights Grid */}
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-5 mt-12 mx-auto max-w-[1100px]"
|
||||
>
|
||||
{HIGHLIGHTS.map((item) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
variants={fadeUp}
|
||||
className="flex items-center gap-4 rounded-2xl border border-[#16001D]/6 bg-white p-4 md:p-5 shadow-sm transition-all hover:border-[#7C3AED]/25 hover:shadow-md"
|
||||
>
|
||||
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${item.iconColor}`}>
|
||||
<MaterialIcon icon={item.icon} size={22} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-black text-[#16001D]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-[#5E5866] font-medium">
|
||||
{item.sub}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
304
src/components/home/CommerceEvolution.tsx
Normal file
304
src/components/home/CommerceEvolution.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { Spotlight } from "@/components/ui/Spotlight";
|
||||
import BorderGlow from "@/components/ui/BorderGlow";
|
||||
import { fadeUp, staggerContainer, staggerCards, cardReveal, viewport } from "@/lib/animations";
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as [number, number, number, number];
|
||||
|
||||
// BorderGlow config for the highlighted AI Commerce card. `glowColor` is an HSL
|
||||
// triplet (the component parses "h s l"), so #A855F7 — the middle of the Nearle
|
||||
// purple family — becomes "271 91 65". The hex values drive the mesh border.
|
||||
const GLOW = {
|
||||
backgroundColor: "#FFFFFF",
|
||||
glowColor: "271 91 65",
|
||||
colors: ["#7C3AED", "#A855F7", "#C084FC"],
|
||||
borderRadius: 28,
|
||||
edgeSensitivity: 28,
|
||||
glowRadius: 34,
|
||||
glowIntensity: 0.85,
|
||||
coneSpread: 22,
|
||||
animated: true,
|
||||
} as const;
|
||||
|
||||
type Era = {
|
||||
step: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
strengths: string[];
|
||||
limits: string[];
|
||||
};
|
||||
|
||||
const ERAS: Era[] = [
|
||||
{
|
||||
step: "01",
|
||||
title: "Traditional Retail",
|
||||
icon: "storefront",
|
||||
strengths: ["Local trust", "Personal relationships", "Community presence"],
|
||||
limits: ["No digital discovery", "Manual operations"],
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "E-Commerce",
|
||||
icon: "public",
|
||||
strengths: ["Massive product catalog", "Nationwide delivery", "Digital convenience"],
|
||||
limits: ["Endless searching", "Generic recommendations", "No local connection"],
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Quick Commerce",
|
||||
icon: "bolt",
|
||||
strengths: ["Fast delivery", "Instant convenience"],
|
||||
limits: ["Dark store dependency", "High operational cost", "Local merchants excluded"],
|
||||
},
|
||||
];
|
||||
|
||||
const AI_POINTS = [
|
||||
{ icon: "psychology", label: "Understands customer intent" },
|
||||
{ icon: "travel_explore", label: "AI-powered discovery" },
|
||||
{ icon: "inventory_2", label: "Real-time inventory" },
|
||||
{ icon: "auto_awesome", label: "Intelligent recommendations" },
|
||||
{ icon: "storefront", label: "Merchant-first commerce" },
|
||||
{ icon: "eco", label: "Sustainable local ecosystem" },
|
||||
];
|
||||
|
||||
export function CommerceEvolution() {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section
|
||||
id="commerce-evolution"
|
||||
aria-labelledby="commerce-evolution-title"
|
||||
className="section relative w-full scroll-mt-24 overflow-hidden bg-white"
|
||||
>
|
||||
{/* Ambient mesh — same violet family as the rest of the AI story */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-0">
|
||||
<div className="absolute left-1/2 top-0 h-[520px] w-[90%] max-w-[1100px] -translate-x-1/2 rounded-full bg-[#A855F7]/[0.07] blur-[140px]" />
|
||||
<div className="ai-orb-a absolute left-[4%] top-[30%] h-[300px] w-[300px] rounded-full bg-[#C084FC]/[0.06] blur-[110px]" />
|
||||
<div className="ai-orb-b absolute right-[4%] bottom-[10%] h-[340px] w-[340px] rounded-full bg-[#F0ABFC]/[0.06] blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="page-container relative z-10">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mx-auto max-w-[820px] text-center"
|
||||
>
|
||||
<motion.div variants={fadeUp} className="inline-flex justify-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[#7C3AED]/20 bg-[linear-gradient(135deg,rgba(124,58,237,0.08)_0%,rgba(168,85,247,0.04)_100%)] px-4 py-1.5 text-xs font-black uppercase tracking-widest text-[#7C3AED] shadow-sm backdrop-blur-md">
|
||||
The Evolution of Commerce
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
id="commerce-evolution-title"
|
||||
variants={fadeUp}
|
||||
className="mt-5 text-balance font-display font-black tracking-tight text-[#16001D]"
|
||||
style={{ fontSize: "clamp(34px, 4.6vw, 62px)", lineHeight: 1.06 }}
|
||||
>
|
||||
Retail Has Entered Its{" "}
|
||||
<span className="bg-gradient-to-r from-[#7C3AED] via-[#A855F7] to-[#6330D6] bg-clip-text text-transparent">
|
||||
Next Era.
|
||||
</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-5 max-w-[680px] text-[17px] leading-[1.75] text-[#5E5866] md:text-[18px]"
|
||||
>
|
||||
Every generation of commerce solved one problem while creating another.
|
||||
</motion.p>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-2.5 max-w-[680px] text-[17px] leading-[1.75] text-[#3B2F44] md:text-[18px]"
|
||||
>
|
||||
AI Commerce combines trust, local merchants, and intelligent discovery into one connected
|
||||
shopping experience.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* The three eras that came before */}
|
||||
<motion.div
|
||||
variants={staggerCards}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mt-14 grid grid-cols-1 gap-5 md:grid-cols-3"
|
||||
>
|
||||
{ERAS.map((era) => (
|
||||
<motion.div
|
||||
key={era.title}
|
||||
variants={cardReveal}
|
||||
whileHover={reduce ? undefined : { y: -6, scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 22 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="flex h-full flex-col rounded-[28px] border border-[#6330D6]/10 bg-white p-6 shadow-[0_10px_30px_rgba(22,0,29,0.05)] transition-shadow duration-300 hover:shadow-[0_20px_40px_-14px_rgba(99,48,214,0.18)] md:p-7"
|
||||
color="rgba(168,85,247,0.10)"
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[#6330D6]/10 bg-[#F5F1FA] text-[#6D6172]">
|
||||
<MaterialIcon icon={era.icon} size={24} />
|
||||
</span>
|
||||
<span className="font-display text-xs font-black uppercase tracking-widest text-[#C9BFD4]">
|
||||
{era.step}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-display text-xl font-black tracking-tight text-[#16001D]">
|
||||
{era.title}
|
||||
</h3>
|
||||
|
||||
{/* flex-1 pushes the limitations block to the card floor, so the
|
||||
three cards share a bottom edge despite uneven list lengths. */}
|
||||
<div className="mt-5 flex-1">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-[#16A34A]">
|
||||
Strengths
|
||||
</p>
|
||||
<ul className="mt-2.5 space-y-2">
|
||||
{era.strengths.map((s) => (
|
||||
<li key={s} className="flex items-start gap-2.5 text-[14px] leading-snug text-[#3F3A47]">
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#DCFCE7] text-[#16A34A]">
|
||||
<MaterialIcon icon="check" size={11} />
|
||||
</span>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-[#6330D6]/8 pt-5">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-[#9A8FA6]">
|
||||
Limitations
|
||||
</p>
|
||||
<ul className="mt-2.5 space-y-2">
|
||||
{era.limits.map((l) => (
|
||||
<li key={l} className="flex items-start gap-2.5 text-[14px] leading-snug text-[#6D6172]">
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#F1EEF5] text-[#9A8FA6]">
|
||||
<MaterialIcon icon="remove" size={11} />
|
||||
</span>
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Spotlight>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Connector into the current era */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
aria-hidden="true"
|
||||
className="flex flex-col items-center py-8"
|
||||
>
|
||||
<span className="h-10 w-px bg-[linear-gradient(180deg,transparent,rgba(124,58,237,0.35))]" />
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#7C3AED]/20 bg-white text-[#7C3AED] shadow-[0_4px_14px_rgba(124,58,237,0.18)]">
|
||||
<MaterialIcon icon="arrow_downward" size={16} />
|
||||
</span>
|
||||
<span className="h-10 w-px bg-[linear-gradient(180deg,rgba(124,58,237,0.35),transparent)]" />
|
||||
</motion.div>
|
||||
|
||||
{/* The highlighted era — AI Commerce */}
|
||||
<motion.div
|
||||
variants={cardReveal}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
whileHover={reduce ? undefined : { y: -6 }}
|
||||
transition={{ type: "spring", stiffness: 240, damping: 24 }}
|
||||
>
|
||||
{/* BorderGlow owns the card surface and carries a fixed six-layer
|
||||
inline box-shadow, so the Nearle elevation is restored through
|
||||
className with Tailwind v4's postfix `!`. */}
|
||||
<BorderGlow
|
||||
{...GLOW}
|
||||
colors={[...GLOW.colors]}
|
||||
className="group [box-shadow:0_16px_44px_-16px_rgba(99,48,214,0.28)]!"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#ffffff_0%,#fbf7ff_55%,#f3ebfd_100%)] p-7 md:p-10 lg:p-12">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-[#A855F7]/12 blur-3xl"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 grid gap-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-14">
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-[#7C3AED]/10 px-3.5 py-1.5 text-[11px] font-black uppercase tracking-[0.14em] text-[#7C3AED] ring-1 ring-[#7C3AED]/20">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-[#7C3AED] opacity-75 motion-safe:animate-ping" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[#7C3AED]" />
|
||||
</span>
|
||||
Now
|
||||
</span>
|
||||
|
||||
<h3
|
||||
className="mt-4 font-display font-black tracking-tight text-[#16001D]"
|
||||
style={{ fontSize: "clamp(30px, 3.4vw, 46px)", lineHeight: 1.08 }}
|
||||
>
|
||||
<span className="bg-gradient-to-r from-[#7C3AED] via-[#A855F7] to-[#6330D6] bg-clip-text text-transparent">
|
||||
AI Commerce
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p className="mt-4 max-w-[420px] text-[15px] leading-[1.75] text-[#5E5866]">
|
||||
The first era that keeps the neighbourhood merchant at the centre while giving shoppers
|
||||
discovery that actually understands them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.ul
|
||||
variants={staggerCards}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||
>
|
||||
{AI_POINTS.map((point) => (
|
||||
<motion.li
|
||||
key={point.label}
|
||||
variants={fadeUp}
|
||||
className="flex items-center gap-3 rounded-2xl border border-[#7C3AED]/12 bg-white/70 p-3.5 shadow-sm backdrop-blur-sm transition-colors duration-200 hover:border-[#7C3AED]/28"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#7C3AED]/10 text-[#7C3AED]">
|
||||
<MaterialIcon icon={point.icon} size={18} />
|
||||
</span>
|
||||
<span className="text-[14px] font-bold leading-snug text-[#16001D]">
|
||||
{point.label}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
transition={{ duration: 0.5, ease: EASE }}
|
||||
className="relative z-10 mt-9 border-t border-[#7C3AED]/12 pt-7 text-center text-[16px] font-bold leading-[1.7] text-[#3B2F44] md:text-[18px]"
|
||||
>
|
||||
Nearle combines{" "}
|
||||
<span className="text-[#7C3AED]">MerchantOS</span>,{" "}
|
||||
<span className="text-[#7C3AED]">ProductLLM</span>,{" "}
|
||||
<span className="text-[#7C3AED]">ConsumerOS</span>, and AI into one intelligent
|
||||
commerce platform.
|
||||
</motion.p>
|
||||
</div>
|
||||
</BorderGlow>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
352
src/components/home/CorePrinciples.tsx
Normal file
352
src/components/home/CorePrinciples.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { Spotlight } from "@/components/ui/Spotlight";
|
||||
import { fadeUp, staggerContainer, staggerSlow, viewport } from "@/lib/animations";
|
||||
|
||||
// 1. Trust Graphic (Emerald Shield & Checkmark)
|
||||
function TrustGraphic({ reduce = false }: { reduce?: boolean }) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center p-2">
|
||||
<motion.div
|
||||
className="absolute h-20 w-20 rounded-full bg-emerald-500/20 blur-xl pointer-events-none"
|
||||
animate={reduce ? {} : { scale: [0.9, 1.25, 0.9], opacity: [0.25, 0.5, 0.25] }}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<svg
|
||||
width="110"
|
||||
height="110"
|
||||
viewBox="0 0 110 110"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative z-10 w-24 h-24 md:w-28 md:h-28 filter drop-shadow-[0_10px_20px_rgba(0,0,0,0.35)] transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
<motion.path
|
||||
d="M 55 12 L 88 24 C 88 56 72 82 55 96 C 38 82 22 56 22 24 L 55 12 Z"
|
||||
fill="url(#trust-grad)"
|
||||
stroke="#6EE7B7"
|
||||
strokeWidth="2"
|
||||
animate={reduce ? {} : { y: [0, -4, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 40 52 L 50 62 L 72 40"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
animate={reduce ? {} : { opacity: [0.8, 1, 0.8] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="trust-grad" x1="22" y1="12" x2="88" y2="96" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#059669" stopOpacity="0.8" />
|
||||
<stop offset="1" stopColor="#34D399" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. AI Graphic (Purple Sparkles Cluster)
|
||||
function AIGraphic({ reduce = false }: { reduce?: boolean }) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center p-2">
|
||||
<motion.div
|
||||
className="absolute h-20 w-20 rounded-full bg-[#7C3AED]/25 blur-xl pointer-events-none"
|
||||
animate={reduce ? {} : { scale: [0.9, 1.25, 0.9], opacity: [0.25, 0.5, 0.25] }}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative z-10 w-24 h-24 md:w-28 md:h-28 filter drop-shadow-[0_10px_20px_rgba(0,0,0,0.35)] transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
<motion.path
|
||||
d="M 45 10 C 45 29.33 29.33 45 10 45 C 29.33 45 45 60.67 45 80 C 45 60.67 60.67 45 80 45 C 60.67 45 45 29.33 45 10 Z"
|
||||
fill="#818CF8"
|
||||
style={{ transformOrigin: "45px 45px" }}
|
||||
animate={reduce ? {} : { scale: [1, 1.08, 1], rotate: [0, 4, 0, -4, 0] }}
|
||||
transition={{ duration: 3.6, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 88 15 C 88 23 81 30 73 30 C 81 30 88 37 88 45 C 88 37 95 30 103 30 C 95 30 88 23 88 15 Z"
|
||||
fill="#C084FC"
|
||||
style={{ transformOrigin: "88px 30px" }}
|
||||
animate={reduce ? {} : { scale: [0.85, 1.2, 0.85], y: [0, -4, 0] }}
|
||||
transition={{ duration: 2.8, delay: 0.4, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 82 72 C 82 78.6 76.6 84 70 84 C 76.6 84 82 89.4 82 96 C 82 89.4 87.4 84 94 84 C 87.4 84 82 78.6 82 72 Z"
|
||||
fill="#F5D0FE"
|
||||
style={{ transformOrigin: "82px 84px" }}
|
||||
animate={reduce ? {} : { scale: [1.15, 0.8, 1.15], rotate: [0, -8, 0] }}
|
||||
transition={{ duration: 3.0, delay: 1.1, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Merchant First Graphic (Golden Storefront & Canopy)
|
||||
function MerchantGraphic({ reduce = false }: { reduce?: boolean }) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center p-2">
|
||||
<motion.div
|
||||
className="absolute h-20 w-20 rounded-full bg-amber-500/20 blur-xl pointer-events-none"
|
||||
animate={reduce ? {} : { scale: [0.9, 1.25, 0.9], opacity: [0.25, 0.5, 0.25] }}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<svg
|
||||
width="110"
|
||||
height="110"
|
||||
viewBox="0 0 110 110"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative z-10 w-24 h-24 md:w-28 md:h-28 filter drop-shadow-[0_10px_20px_rgba(0,0,0,0.35)] transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
<motion.path
|
||||
d="M 20 40 L 90 40 L 82 22 L 28 22 Z"
|
||||
fill="#FBBF24"
|
||||
animate={reduce ? {} : { y: [0, -3, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<path d="M 34 22 L 30 40 M 48 22 L 46 40 M 62 22 L 62 40 M 76 22 L 78 40" stroke="#D97706" strokeWidth="2.5" />
|
||||
<rect x="25" y="40" width="60" height="46" rx="4" fill="url(#merchant-grad)" stroke="#FDE047" strokeWidth="2" />
|
||||
<rect x="43" y="56" width="24" height="30" rx="3" fill="#78350F" />
|
||||
<circle cx="55" cy="48" r="4" fill="#FDE047" />
|
||||
<defs>
|
||||
<linearGradient id="merchant-grad" x1="25" y1="40" x2="85" y2="86" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#D97706" stopOpacity="0.8" />
|
||||
<stop offset="1" stopColor="#F59E0B" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Local Graphic (Sky Pin & Radar Pulse)
|
||||
function LocalGraphic({ reduce = false }: { reduce?: boolean }) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center p-2">
|
||||
<motion.div
|
||||
className="absolute h-20 w-20 rounded-full bg-sky-500/20 blur-xl pointer-events-none"
|
||||
animate={reduce ? {} : { scale: [0.9, 1.25, 0.9], opacity: [0.25, 0.5, 0.25] }}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<svg
|
||||
width="110"
|
||||
height="110"
|
||||
viewBox="0 0 110 110"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative z-10 w-24 h-24 md:w-28 md:h-28 filter drop-shadow-[0_10px_20px_rgba(0,0,0,0.35)] transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
<motion.path
|
||||
d="M 55 18 C 40 18 28 30 28 45 C 28 65 55 88 55 88 C 55 88 82 65 82 45 C 82 30 70 18 55 18 Z"
|
||||
fill="url(#local-grad)"
|
||||
stroke="#7DD3FC"
|
||||
strokeWidth="2"
|
||||
animate={reduce ? {} : { y: [0, -6, 0] }}
|
||||
transition={{ duration: 2.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<circle cx="55" cy="43" r="9" fill="#0284C7" />
|
||||
<circle cx="55" cy="43" r="4" fill="#FFFFFF" />
|
||||
<defs>
|
||||
<linearGradient id="local-grad" x1="28" y1="18" x2="82" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#0284C7" stopOpacity="0.8" />
|
||||
<stop offset="1" stopColor="#38BDF8" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_MOTES = [
|
||||
{ left: "20%", top: "35%", size: 4, duration: 4.5, delay: 0 },
|
||||
{ left: "75%", top: "60%", size: 3, duration: 5.5, delay: 0.8 },
|
||||
];
|
||||
|
||||
const PRINCIPLES = [
|
||||
{
|
||||
word: "Trust",
|
||||
icon: "verified_user",
|
||||
line: "Local relationships cannot be replaced.",
|
||||
badgeStyle: "bg-emerald-500/15 border-emerald-500/30 text-emerald-400",
|
||||
dotColor: "bg-emerald-400",
|
||||
spotlightColor: "rgba(52,211,153,0.18)",
|
||||
renderGraphic: (reduce: boolean) => <TrustGraphic reduce={reduce} />,
|
||||
},
|
||||
{
|
||||
word: "AI",
|
||||
icon: "psychology",
|
||||
line: "Removes complexity instead of adding more choices.",
|
||||
badgeStyle: "bg-[#7C3AED]/20 border-[#7C3AED]/40 text-[#C084FC]",
|
||||
dotColor: "bg-[#C084FC]",
|
||||
spotlightColor: "rgba(168,85,247,0.20)",
|
||||
renderGraphic: (reduce: boolean) => <AIGraphic reduce={reduce} />,
|
||||
},
|
||||
{
|
||||
word: "Merchant First",
|
||||
icon: "storefront",
|
||||
line: "Technology should empower neighbourhood businesses.",
|
||||
badgeStyle: "bg-amber-500/15 border-amber-500/30 text-amber-400",
|
||||
dotColor: "bg-amber-400",
|
||||
spotlightColor: "rgba(251,191,36,0.18)",
|
||||
renderGraphic: (reduce: boolean) => <MerchantGraphic reduce={reduce} />,
|
||||
},
|
||||
{
|
||||
word: "Local",
|
||||
icon: "near_me",
|
||||
line: "Every recommendation starts with nearby trusted merchants.",
|
||||
badgeStyle: "bg-sky-500/15 border-sky-500/30 text-sky-400",
|
||||
dotColor: "bg-sky-400",
|
||||
spotlightColor: "rgba(56,189,248,0.18)",
|
||||
renderGraphic: (reduce: boolean) => <LocalGraphic reduce={reduce} />,
|
||||
},
|
||||
];
|
||||
|
||||
export function CorePrinciples() {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="core-principles-title"
|
||||
className="w-full bg-white px-2 py-6 sm:px-4 md:py-8"
|
||||
>
|
||||
<div className="mx-auto max-w-[1840px]">
|
||||
<div className="section-card-dark relative px-6 py-10 text-white md:px-12 md:py-12 lg:px-14 lg:py-14">
|
||||
{/* Ambient purple wash */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute left-[-8%] top-[-10%] h-[420px] w-[420px] rounded-full bg-[#7C3AED]/18 blur-[130px]" />
|
||||
<div className="absolute right-[-6%] bottom-[-12%] h-[380px] w-[380px] rounded-full bg-[#A855F7]/12 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="relative z-10 max-w-[1100px]"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/15 px-3.5 py-1 text-xs font-black uppercase tracking-widest text-white/70"
|
||||
>
|
||||
What We Build On
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
id="core-principles-title"
|
||||
variants={fadeUp}
|
||||
className="mt-3 text-balance font-display font-black tracking-tight"
|
||||
style={{ fontSize: "clamp(32px, 3.8vw, 54px)", lineHeight: 1.06 }}
|
||||
>
|
||||
Four principles behind{" "}
|
||||
<span className="text-[#C084FC]">every decision.</span>
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Editorial principle blocks with left text and right distinct SVG graphic */}
|
||||
<motion.div
|
||||
variants={staggerSlow}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="relative z-10 mt-8 grid gap-5 overflow-hidden rounded-3xl md:mt-10 md:grid-cols-2 lg:gap-7"
|
||||
>
|
||||
{PRINCIPLES.map((principle) => (
|
||||
<motion.div
|
||||
key={principle.word}
|
||||
variants={fadeUp}
|
||||
whileHover={reduce ? undefined : { y: -5, scale: 1.008 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 22 }}
|
||||
className="group relative h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="relative overflow-hidden h-full rounded-3xl border border-white/10 bg-[#150320] p-6 md:p-8 lg:p-9 transition-all duration-300 group-hover:border-white/25 group-hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.5)]"
|
||||
color={principle.spotlightColor}
|
||||
size={460}
|
||||
>
|
||||
{/* Floating Flying Particle Glow Dots (Motes) - Shown ONLY on Hover */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden z-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100">
|
||||
{CARD_MOTES.map((mote, idx) => (
|
||||
<motion.span
|
||||
key={idx}
|
||||
className={`absolute rounded-full ${principle.dotColor} shadow-[0_0_8px_currentColor]`}
|
||||
style={{
|
||||
left: mote.left,
|
||||
top: mote.top,
|
||||
width: mote.size,
|
||||
height: mote.size,
|
||||
}}
|
||||
animate={
|
||||
reduce
|
||||
? {}
|
||||
: {
|
||||
y: [0, -12, 0],
|
||||
x: [0, 6, 0],
|
||||
opacity: [0.3, 0.9, 0.3],
|
||||
scale: [0.8, 1.2, 0.8],
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
duration: mote.duration,
|
||||
delay: mote.delay,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Corner Hover Glowing Dot */}
|
||||
<div className="absolute top-5 right-5 z-20 flex items-center gap-2 opacity-50 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full ${principle.dotColor} opacity-75`} />
|
||||
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${principle.dotColor} shadow-[0_0_8px_currentColor]`} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ambient Corner Glow Orb */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute -bottom-10 -right-10 h-44 w-44 rounded-full blur-3xl opacity-20 transition-all duration-500 group-hover:scale-150 group-hover:opacity-50 ${principle.dotColor}`}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-6">
|
||||
{/* Left Column: Badge + Title + Description */}
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-2xl border ${principle.badgeStyle} mb-4 shadow-sm transition-transform duration-300 group-hover:scale-110`}>
|
||||
<MaterialIcon icon={principle.icon} size={20} />
|
||||
</span>
|
||||
|
||||
<h3 className="font-display font-black text-white text-xl md:text-2xl lg:text-3xl tracking-tight mb-2">
|
||||
{principle.word}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm md:text-base leading-relaxed text-white/75 max-w-[500px]">
|
||||
{principle.line}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Tailored Animated SVG Graphic */}
|
||||
<div className="shrink-0 flex items-center justify-center self-center">
|
||||
{principle.renderGraphic(reduce)}
|
||||
</div>
|
||||
</div>
|
||||
</Spotlight>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { Fragment, useRef, type MouseEvent } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion, useReducedMotion, useMotionValue, useSpring, useTransform } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { MagneticButton } from "@/components/ui/MagneticButton";
|
||||
import { HeroLogo } from "@/components/heroes/HeroLogo";
|
||||
import { viewport } from "@/lib/animations";
|
||||
|
||||
@@ -26,7 +28,7 @@ const badgeVariant = {
|
||||
|
||||
const lineStagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.15, delayChildren: 1.05 } },
|
||||
visible: { transition: { staggerChildren: 0.13, delayChildren: 1.0 } },
|
||||
};
|
||||
|
||||
const lineReveal = {
|
||||
@@ -36,7 +38,12 @@ const lineReveal = {
|
||||
|
||||
const descVariant = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, delay: 1.25, ease: EASE } },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, delay: 1.4, ease: EASE } },
|
||||
};
|
||||
|
||||
const ctaVariant = {
|
||||
hidden: { opacity: 0, y: 22 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.55, delay: 1.6, ease: EASE } },
|
||||
};
|
||||
|
||||
const statStagger = {
|
||||
@@ -106,49 +113,88 @@ export function Hero() {
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
/>
|
||||
|
||||
<div className="page-container relative z-10 flex min-h-[560px] items-center justify-center px-6 pt-20 pb-10 sm:px-8 sm:pt-24 md:min-h-[85vh] md:py-16 lg:py-16">
|
||||
<div className="page-container relative z-10 flex min-h-[560px] items-center justify-center px-6 pt-20 pb-10 sm:px-8 sm:pt-24 md:min-h-[85vh] md:py-6 lg:py-8">
|
||||
|
||||
<div className="relative z-20 flex w-full max-w-[860px] flex-col items-center text-center px-6 py-8 md:px-14 md:py-6 lg:py-8">
|
||||
<HeroLogo />
|
||||
{/* `compact` logo spacing: this hero now carries CTAs and the evolution
|
||||
strip inside it, the same reason BrandsHero uses it. */}
|
||||
<div className="relative z-20 flex w-full max-w-[860px] flex-col items-center text-center px-6 py-6 md:px-14 md:py-2 lg:py-3">
|
||||
<HeroLogo spacing="compact" />
|
||||
|
||||
{/* Badge */}
|
||||
<motion.span
|
||||
variants={badgeVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-bold tracking-widest bg-white text-[#6D28D9] shadow-md uppercase mb-6 w-fit"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-bold tracking-widest bg-white text-[#6D28D9] shadow-md uppercase mb-5 w-fit"
|
||||
>
|
||||
Welcome to Nearle
|
||||
</motion.span>
|
||||
|
||||
{/* H1: line-by-line reveal */}
|
||||
{/* H1: line-by-line reveal. Two lines, with the closing word
|
||||
highlighted in the Nearle purple. */}
|
||||
<motion.h1
|
||||
variants={lineStagger}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="font-display text-[34px] sm:text-5xl lg:text-[68px] xl:text-[76px] font-extrabold text-white tracking-[-0.05em] [text-shadow:0_4px_32px_rgba(0,0,0,0.5)]"
|
||||
className="font-display font-extrabold text-white tracking-[-0.045em] [text-shadow:0_4px_32px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<div className="overflow-hidden pb-1">
|
||||
<motion.div variants={lineReveal} className="leading-[0.95]">
|
||||
<span className="block overflow-hidden pb-1">
|
||||
<motion.span
|
||||
variants={lineReveal}
|
||||
className="block text-[30px] leading-[1.06] text-balance sm:text-[40px] lg:text-[50px] xl:text-[56px]"
|
||||
>
|
||||
Everything Local.
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="overflow-hidden pb-1">
|
||||
<motion.div variants={lineReveal} className="leading-[0.95]">
|
||||
</motion.span>
|
||||
</span>
|
||||
<span className="block overflow-hidden pb-1">
|
||||
<motion.span
|
||||
variants={lineReveal}
|
||||
className="block text-[30px] leading-[1.06] text-balance sm:text-[40px] lg:text-[50px] xl:text-[56px]"
|
||||
>
|
||||
Everything <span className="text-[#A855F7]">Near.</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.span>
|
||||
</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
<motion.div
|
||||
variants={descVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="max-w-[520px] text-lg md:text-xl text-white/95 leading-[1.6] font-medium mt-6 [text-shadow:0_2px_16px_rgba(0,0,0,0.6)]"
|
||||
className="mt-5 max-w-[700px] [text-shadow:0_2px_16px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
Discover verified local stores, exclusive offers, fast delivery, and a better way to shop within your neighbourhood.
|
||||
</motion.p>
|
||||
<p className="text-base md:text-lg text-white/90 leading-[1.6] font-medium">
|
||||
Discover verified local stores, exclusive offers, fast delivery, and a
|
||||
better way to shop within your neighbourhood.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
variants={ctaVariant}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="mt-7 flex flex-wrap items-center justify-center gap-3.5"
|
||||
>
|
||||
<MagneticButton>
|
||||
<Link
|
||||
href="#ai-commerce"
|
||||
className="flex items-center gap-2 rounded-xl bg-[#6330D6] px-7 py-3.5 text-sm font-black text-white shadow-lg transition-all duration-200 hover:bg-[#5225b8] hover:shadow-xl active:scale-95 sm:px-8"
|
||||
>
|
||||
<MaterialIcon icon="auto_awesome" size={18} />
|
||||
<span>Explore AI Commerce</span>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
<MagneticButton>
|
||||
<Link
|
||||
href="#nearle-in-action"
|
||||
className="flex items-center gap-2 rounded-xl border border-white/25 bg-white/10 px-7 py-3.5 text-sm font-black text-white backdrop-blur-md transition-all duration-200 hover:bg-white/20 active:scale-95 sm:px-8"
|
||||
>
|
||||
<MaterialIcon icon="play_circle" size={18} />
|
||||
<span>See How Nearle Works</span>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,37 @@
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { fadeUp, scaleIn, slideInLeft, viewport } from "@/lib/animations";
|
||||
import { MerchantJourney } from "@/components/home/merchant/MerchantJourney";
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
function OrdersCard() {
|
||||
return (
|
||||
@@ -80,9 +109,169 @@ 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]">
|
||||
@@ -147,32 +336,62 @@ 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>
|
||||
|
||||
{/* 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">
|
||||
{/* CARDS SECTION */}
|
||||
<div className="mx-auto w-full max-w-[2200px] px-6 lg:px-10 xl:px-12">
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl px-6 text-center"
|
||||
className="mt-10 text-center md:mt-14 xl:mt-10"
|
||||
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">
|
||||
Merchant Journey
|
||||
How It Works
|
||||
</span>
|
||||
<h2 className="mt-5 font-display text-4xl font-black leading-tight tracking-tight text-brand-dark md:text-5xl lg:text-[54px]">
|
||||
From your shelf to a growing business
|
||||
Build Your Neighbourhood Brand
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-on-surface-variant md:text-lg">
|
||||
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.
|
||||
Three simple milestones to scale your retail reach from offline streets straight into neighbourhood homes.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<MerchantJourney />
|
||||
</section>
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion, useScroll, useTransform } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { PhoneStage } from "@/components/home/voice/PhoneStage";
|
||||
import { FloatingRequestCards } from "@/components/home/voice/FloatingRequestCards";
|
||||
@@ -11,171 +12,183 @@ 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 = {
|
||||
const headStagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.14, delayChildren: 0.12 } },
|
||||
visible: { transition: { staggerChildren: 0.14 } },
|
||||
};
|
||||
|
||||
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 } },
|
||||
hidden: { y: 44, opacity: 0, filter: "blur(10px)" },
|
||||
visible: { y: 0, opacity: 1, filter: "blur(0px)", transition: { duration: 0.9, 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 } },
|
||||
hidden: { opacity: 0, y: 20, filter: "blur(8px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 0.95, delay: 0.34, ease: EASE_OUT },
|
||||
},
|
||||
};
|
||||
|
||||
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 } },
|
||||
hidden: { opacity: 0, y: 56, scale: 0.955 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 1.15, delay: 0.46, 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.
|
||||
const captionVariant = {
|
||||
hidden: { opacity: 0, y: 14 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.8, delay: 1.05, ease: EASE } },
|
||||
};
|
||||
|
||||
const glowVariant = {
|
||||
hidden: { opacity: 0, scale: 0.88 },
|
||||
visible: { opacity: 1, scale: 1, transition: { duration: 1.7, ease: EASE } },
|
||||
};
|
||||
|
||||
// The homepage's product moment: one large floating device, centred, running a
|
||||
// real Nearle request end to end — spoken words, AI reasoning, a nearby
|
||||
// merchant, the order, the rider, the doorstep. The copy above it is
|
||||
// deliberately two lines; everything on this canvas exists to hold the eye on
|
||||
// the phone.
|
||||
export function VoiceCommerceExperience() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
// Soft parallax: the device drifts against the page as the section passes,
|
||||
// which reads as depth rather than as movement.
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
const phoneY = useTransform(scrollYProgress, [0, 1], [34, -34]);
|
||||
const auraY = useTransform(scrollYProgress, [0, 1], [-26, 26]);
|
||||
|
||||
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%)",
|
||||
}}
|
||||
ref={sectionRef}
|
||||
id="nearle-in-action"
|
||||
aria-labelledby="nearle-in-action-title"
|
||||
className="relative w-full overflow-hidden border-b border-[#F1EAF8] bg-white pb-20 pt-24 lg:pb-24 lg:pt-28"
|
||||
>
|
||||
{/* 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" />
|
||||
{/* ─── Ambient canvas ─── */}
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
>
|
||||
{/* the bloom the device stands in */}
|
||||
<motion.div
|
||||
variants={glowVariant}
|
||||
style={reduced ? undefined : { y: auraY }}
|
||||
className="absolute left-1/2 top-[46%] h-[720px] w-[min(92%,980px)] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[#A855F7]/[0.10] blur-[150px]"
|
||||
/>
|
||||
{/* two quiet mesh blobs, kept in the outer margins */}
|
||||
<motion.div variants={glowVariant} className="absolute inset-0">
|
||||
<div className="ai-orb-a absolute left-[3%] top-[30%] h-[340px] w-[340px] rounded-full bg-[#C084FC]/[0.09] blur-[120px]" />
|
||||
<div className="ai-orb-b absolute right-[2%] top-[48%] h-[400px] w-[400px] rounded-full bg-[#F0ABFC]/[0.08] blur-[130px]" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<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"
|
||||
{/* Voice waves, centred on the device rather than pushed to one side */}
|
||||
<VoiceWaves className="z-0" />
|
||||
|
||||
<div className="page-container relative z-10 flex flex-col items-center">
|
||||
{/* ─── Heading + one supporting line ─── */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mx-auto max-w-[820px] text-center"
|
||||
>
|
||||
<motion.h2
|
||||
id="nearle-in-action-title"
|
||||
variants={headStagger}
|
||||
className="font-display font-black tracking-[-0.048em] text-[#16001D]"
|
||||
style={{ fontSize: "clamp(36px, 4.6vw, 62px)", lineHeight: 1.04 }}
|
||||
>
|
||||
<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>
|
||||
<span className="block overflow-hidden pb-1">
|
||||
<motion.span variants={lineReveal} className="block">
|
||||
Experience Nearle
|
||||
</motion.span>
|
||||
</span>
|
||||
<span className="block overflow-hidden pb-1">
|
||||
<motion.span variants={lineReveal} className="block text-[#7C3AED]">
|
||||
in Action
|
||||
</motion.span>
|
||||
</span>
|
||||
</motion.h2>
|
||||
|
||||
<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"
|
||||
<motion.p
|
||||
variants={descVariant}
|
||||
className="mx-auto mt-6 max-w-[600px] text-base font-medium leading-[1.7] text-[#4A3F52] md:text-[19px]"
|
||||
>
|
||||
{/* 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>
|
||||
Say what you need in your own words. Nearle finds it at a shop around the corner, places
|
||||
the order, and tracks it to your door.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── The device ─── */}
|
||||
<motion.div
|
||||
variants={phoneVariant}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
style={reduced ? undefined : { y: phoneY }}
|
||||
className="relative mt-14 flex w-full justify-center lg:mt-16"
|
||||
>
|
||||
{/* Width-driven at every breakpoint — the 476/941 aspect ratio derives
|
||||
the height, so the device never collapses. Height-relative on
|
||||
desktop so the taller phone still fits a short laptop. */}
|
||||
<div className="relative w-[min(80vw,352px)] lg:w-[min(46vh,470px)]">
|
||||
{/* tight glow anchoring the device to the canvas */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[88%] w-[165%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#A855F7]/[0.14] blur-[110px]"
|
||||
/>
|
||||
<PhoneStage className="w-full" />
|
||||
{/* centring the device frees both gutters, so every request card
|
||||
now has room to drift without ever covering the screen */}
|
||||
<FloatingRequestCards />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── One quiet line under the device ─── */}
|
||||
<motion.div
|
||||
variants={captionVariant}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
// Below lg the request cards sit above and below the device instead
|
||||
// of in the gutters, so the caption needs real clearance there.
|
||||
className="mt-16 flex flex-col items-center gap-4 lg:mt-6"
|
||||
>
|
||||
<p className="flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.18em] text-[#6D28D9]/70">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-[#7C3AED] opacity-60" />
|
||||
</span>
|
||||
Live AI Commerce demo
|
||||
</p>
|
||||
<Link
|
||||
href="/platform"
|
||||
className="group inline-flex items-center gap-2 text-sm font-bold text-[#4A3F52] transition-colors duration-200 hover:text-[#6330D6]"
|
||||
>
|
||||
See how the platform works
|
||||
<MaterialIcon
|
||||
icon="arrow_forward"
|
||||
size={16}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,46 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { fadeUp, fadeIn, staggerContainer, viewport } from "@/lib/animations";
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
title: "Understands your intent",
|
||||
desc: "Reads natural language and knows what you actually need.",
|
||||
icon: "psychology",
|
||||
title: "Neighbourhood Trust",
|
||||
desc: "Every order is fulfilled by a real local shop your street already knows — not an anonymous warehouse.",
|
||||
icon: "handshake",
|
||||
color: "bg-primary/5 text-primary border-primary/10",
|
||||
},
|
||||
{
|
||||
title: "Finds matching products",
|
||||
desc: "Maps your intent to real items on your store’s shelves.",
|
||||
icon: "inventory_2",
|
||||
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",
|
||||
color: "bg-secondary/5 text-secondary border-secondary/10",
|
||||
},
|
||||
{
|
||||
title: "Recommends alternatives",
|
||||
desc: "Out of stock? It suggests the closest match in the store.",
|
||||
icon: "autorenew",
|
||||
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",
|
||||
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">
|
||||
@@ -52,43 +38,39 @@ 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">
|
||||
The Assistant
|
||||
AI Commerce
|
||||
</span>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-[54px] font-display font-black text-brand-dark mt-3 leading-tight tracking-tight">
|
||||
What Nearle AI <span className="text-primary font-normal">Does</span>
|
||||
Why AI Commerce <span className="text-primary font-normal">Wins</span>
|
||||
</h2>
|
||||
<p className="text-body-lg text-on-surface-variant mt-2.5 max-w-xl mx-auto leading-relaxed">
|
||||
The store stays the hero. Nearle AI is the quiet assistant that helps you
|
||||
find what you need inside it — faster.
|
||||
Nearle isn’t another delivery app. It’s the AI layer that brings
|
||||
neighbourhood stores online — without replacing them.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-5"
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-5 lg:gap-7"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
>
|
||||
{CARDS.map((card, i) => (
|
||||
{CARDS.map((card) => (
|
||||
<motion.div
|
||||
key={card.title}
|
||||
variants={fadeIn}
|
||||
className="premium-card bg-white rounded-3xl p-5 md:p-6 border border-outline-variant/15 shadow-lg flex flex-col h-full"
|
||||
className="premium-card bg-white rounded-3xl p-6 md:p-7 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 } }}
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
{card.title}
|
||||
</p>
|
||||
<p className="text-sm text-on-surface-variant leading-relaxed">
|
||||
<p className="text-sm md:text-base text-on-surface-variant leading-relaxed">
|
||||
{card.desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
174
src/components/home/WhyNearleDifferent.tsx
Normal file
174
src/components/home/WhyNearleDifferent.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import { Spotlight } from "@/components/ui/Spotlight";
|
||||
import {
|
||||
fadeUp,
|
||||
slideInLeftSubtle,
|
||||
slideInRight,
|
||||
staggerContainer,
|
||||
staggerCards,
|
||||
viewport,
|
||||
} from "@/lib/animations";
|
||||
|
||||
const TRADITIONAL = [
|
||||
{ icon: "hub", text: "Platform owns discovery" },
|
||||
{ icon: "visibility_off", text: "Merchant becomes invisible" },
|
||||
{ icon: "search", text: "Endless searching" },
|
||||
{ icon: "groups", text: "Customer belongs to platform" },
|
||||
];
|
||||
|
||||
const NEARLE = [
|
||||
{ icon: "psychology", text: "AI understands intent" },
|
||||
{ icon: "storefront", text: "Merchant stays at the center" },
|
||||
{ icon: "near_me", text: "Customers discover nearby stores naturally" },
|
||||
{ icon: "handshake", text: "Technology empowers merchants instead of replacing them" },
|
||||
];
|
||||
|
||||
export function WhyNearleDifferent() {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="why-different-title"
|
||||
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-[760px] text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="inline-flex items-center rounded-full bg-[#6330D6]/[0.08] px-3.5 py-1.5 text-xs font-black uppercase tracking-widest text-[#6330D6]"
|
||||
>
|
||||
The Difference
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
id="why-different-title"
|
||||
variants={fadeUp}
|
||||
className="mt-4 font-display font-black tracking-tight text-[#16001D]"
|
||||
style={{ fontSize: "clamp(32px, 4vw, 54px)", lineHeight: 1.08 }}
|
||||
>
|
||||
Why Nearle Is Different
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-4 max-w-[600px] text-[17px] leading-[1.7] text-[#5E5866]"
|
||||
>
|
||||
Two ways to bring shopping online. One puts the platform in the middle — the other keeps
|
||||
the shop you already trust there.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-14 grid gap-5 lg:grid-cols-2 lg:gap-6">
|
||||
{/* Traditional commerce */}
|
||||
<motion.div
|
||||
variants={slideInLeftSubtle}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
whileHover={reduce ? undefined : { y: -6, scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 22 }}
|
||||
>
|
||||
<div className="flex h-full flex-col rounded-[32px] border border-[#16001D]/8 bg-white p-7 shadow-[0_10px_30px_rgba(22,0,29,0.05)] md:p-9">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#F1EEF5] text-[#6D6172]">
|
||||
<MaterialIcon icon="apartment" size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-[#9A8FA6]">
|
||||
Today
|
||||
</p>
|
||||
<h3 className="font-display text-xl font-black tracking-tight text-[#16001D]">
|
||||
Traditional Commerce
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.ul
|
||||
variants={staggerCards}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mt-7 space-y-3"
|
||||
>
|
||||
{TRADITIONAL.map((item) => (
|
||||
<motion.li
|
||||
key={item.text}
|
||||
variants={fadeUp}
|
||||
className="flex items-center gap-3 rounded-2xl border border-[#16001D]/6 bg-[#FAF9FB] p-3.5"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white text-[#9A8FA6] shadow-sm">
|
||||
<MaterialIcon icon={item.icon} size={18} />
|
||||
</span>
|
||||
<span className="text-[15px] font-medium leading-snug text-[#5E5866]">
|
||||
{item.text}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Nearle AI Commerce — highlighted */}
|
||||
<motion.div
|
||||
variants={slideInRight}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
whileHover={reduce ? undefined : { y: -6, scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 22 }}
|
||||
>
|
||||
<Spotlight
|
||||
className="flex h-full flex-col overflow-hidden rounded-[32px] border border-[#6330D6]/20 bg-[linear-gradient(135deg,#ffffff_0%,#fbf7ff_55%,#f1e8fb_100%)] p-7 shadow-[0_16px_44px_-16px_rgba(99,48,214,0.28)] md:p-9"
|
||||
color="rgba(168,85,247,0.14)"
|
||||
size={420}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#7C3AED]/10 text-[#7C3AED] ring-1 ring-[#7C3AED]/15">
|
||||
<MaterialIcon icon="auto_awesome" size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-[#7C3AED]">
|
||||
Nearle
|
||||
</p>
|
||||
<h3 className="font-display text-xl font-black tracking-tight text-[#16001D]">
|
||||
Nearle AI Commerce
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.ul
|
||||
variants={staggerCards}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewport}
|
||||
className="mt-7 space-y-3"
|
||||
>
|
||||
{NEARLE.map((item) => (
|
||||
<motion.li
|
||||
key={item.text}
|
||||
variants={fadeUp}
|
||||
className="flex items-center gap-3 rounded-2xl border border-[#7C3AED]/12 bg-white/75 p-3.5 shadow-sm backdrop-blur-sm transition-colors duration-200 hover:border-[#7C3AED]/28"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#7C3AED]/10 text-[#7C3AED]">
|
||||
<MaterialIcon icon={item.icon} size={18} />
|
||||
</span>
|
||||
<span className="text-[15px] font-bold leading-snug text-[#16001D]">
|
||||
{item.text}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</Spotlight>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -65,41 +65,41 @@ export function MerchantJourney() {
|
||||
</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">
|
||||
<div className="mt-10 w-full max-w-3xl lg:max-w-4xl px-2">
|
||||
<div className="relative h-1 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 }}
|
||||
transition={{ duration: reduced ? 0 : 0.4, ease: EASE }}
|
||||
/>
|
||||
</div>
|
||||
<ol className="mt-3.5 flex justify-between">
|
||||
<ol className="mt-4 flex justify-between gap-1 sm:gap-2">
|
||||
{steps.map((s, i) => {
|
||||
const active = i === index;
|
||||
const done = i < index;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<li key={s.id} className="flex-1 flex justify-center">
|
||||
<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"
|
||||
className="group flex flex-col items-center gap-2 p-1 focus:outline-none"
|
||||
>
|
||||
<span
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full border-2 transition-all duration-300 ${
|
||||
className={`flex h-10 w-10 sm:h-12 sm:w-12 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)]"
|
||||
? "scale-110 border-primary bg-primary text-white shadow-[0_8px_20px_rgba(101,20,154,0.35)]"
|
||||
: done
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-primary/20 bg-white text-on-surface-variant/70 group-hover:border-primary/40"
|
||||
? "border-primary/60 bg-primary/10 text-primary group-hover:border-primary group-hover:bg-primary/20"
|
||||
: "border-primary/20 bg-white text-on-surface-variant/70 group-hover:border-primary/50 group-hover:bg-primary/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<MaterialIcon icon={s.icon} size={14} />
|
||||
<MaterialIcon icon={s.icon} size={22} />
|
||||
</span>
|
||||
<span
|
||||
className={`hidden text-[10px] font-black transition-colors duration-300 sm:block ${
|
||||
active ? "text-brand-dark" : "text-on-surface-variant/45"
|
||||
className={`text-[10px] sm:text-xs font-bold transition-colors duration-300 text-center ${
|
||||
active ? "text-brand-dark font-black" : "text-on-surface-variant/60 group-hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
@@ -112,24 +112,24 @@ export function MerchantJourney() {
|
||||
</div>
|
||||
|
||||
{/* Controls — manual stepping + replay once the arc has played */}
|
||||
<div className="mt-7 flex items-center gap-2.5">
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<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"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-primary/20 text-primary transition-all hover:bg-primary/[0.08] hover:border-primary/40 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<MaterialIcon icon="arrow_back" size={18} />
|
||||
<MaterialIcon icon="arrow_back" size={20} />
|
||||
</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"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-primary/20 text-primary transition-all hover:bg-primary/[0.08] hover:border-primary/40 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<MaterialIcon icon="arrow_forward" size={18} />
|
||||
<MaterialIcon icon="arrow_forward" size={20} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -140,9 +140,9 @@ export function MerchantJourney() {
|
||||
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"
|
||||
className="ml-2 inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-primary-container hover:shadow-lg"
|
||||
>
|
||||
<MaterialIcon icon="replay" size={17} />
|
||||
<MaterialIcon icon="replay" size={18} />
|
||||
Replay
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
@@ -3,233 +3,322 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
||||
import type { MerchantStep, MerchantStageId } from "@/hooks/useMerchantJourney";
|
||||
import { useRealTimeClock } from "@/hooks/useRealTimeClock";
|
||||
|
||||
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 },
|
||||
const screenVariant = {
|
||||
initial: { opacity: 0, y: 6, scale: 0.98 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
exit: { opacity: 0, y: -6, scale: 0.98 },
|
||||
transition: { duration: 0.35, 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 }) {
|
||||
const liveTime = useRealTimeClock();
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full max-w-[250px] items-center justify-center">
|
||||
<div className="relative mx-auto flex w-full max-w-[260px] items-center justify-center">
|
||||
{/* Ambient background 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"
|
||||
className="pointer-events-none absolute inset-0 -z-10 scale-125 rounded-full bg-gradient-to-tr from-primary/25 via-[#9333ea]/20 to-[#a855f7]/25 blur-3xl"
|
||||
/>
|
||||
|
||||
{/* Floating phone wrapper with gentle levitation */}
|
||||
<motion.div
|
||||
animate={reduced ? undefined : { y: [-5, 5, -5] }}
|
||||
animate={reduced ? undefined : { y: [-6, 6, -6], rotate: [-0.5, 0.5, -0.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)]"
|
||||
className="relative z-10 aspect-[9/18.8] w-full select-none"
|
||||
>
|
||||
<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>
|
||||
{/* Physical Side Buttons */}
|
||||
{/* Left: Volume buttons */}
|
||||
<div className="absolute -left-[3px] top-[70px] h-6 w-[3px] rounded-l-sm bg-neutral-700 shadow-sm" />
|
||||
<div className="absolute -left-[3px] top-[102px] h-6 w-[3px] rounded-l-sm bg-neutral-700 shadow-sm" />
|
||||
{/* Right: Power button */}
|
||||
<div className="absolute -right-[3px] top-[84px] h-9 w-[3px] rounded-r-sm bg-neutral-700 shadow-sm" />
|
||||
|
||||
{/* Outer Phone Frame Shell - Sleek modern radius */}
|
||||
<div className="relative h-full w-full rounded-[30px] bg-neutral-950 p-[6px] shadow-[0_24px_50px_rgba(22,0,29,0.25),0_8px_20px_rgba(0,0,0,0.2)] ring-1 ring-white/20">
|
||||
|
||||
{/* Inner Bezel Frame */}
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-[24px] bg-[#FBF8FE] border border-neutral-900">
|
||||
|
||||
{/* OS Status Bar Overlay: Time on Far Left, Cellular Signal Tower & Battery on Far Right */}
|
||||
<div className="absolute inset-x-0 top-0 z-40 flex h-8 items-center justify-between px-4.5 text-white pointer-events-none select-none">
|
||||
<span className="text-[10px] font-bold tracking-tight">{liveTime || "9:41"}</span>
|
||||
<div className="flex items-center gap-1.5 text-white/95">
|
||||
{/* Cellular Signal Tower Bars */}
|
||||
<div className="flex items-end gap-[1.5px] h-2.5">
|
||||
<span className="w-[2px] h-[3px] bg-white rounded-[0.5px]" />
|
||||
<span className="w-[2px] h-[5px] bg-white rounded-[0.5px]" />
|
||||
<span className="w-[2px] h-[7px] bg-white rounded-[0.5px]" />
|
||||
<span className="w-[2px] h-[9px] bg-white rounded-[0.5px]" />
|
||||
</div>
|
||||
{/* Battery Icon */}
|
||||
<div className="flex items-center">
|
||||
<div className="h-2.5 w-4.5 rounded-[3px] border border-white p-[1px] flex items-center">
|
||||
<div className="h-full w-3 rounded-[1.5px] bg-white" />
|
||||
</div>
|
||||
<div className="h-1 w-[1px] bg-white rounded-r-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Island - Centered between Time and Tower icons */}
|
||||
<div className="absolute left-1/2 top-2 z-40 flex h-3.5 w-[64px] -translate-x-1/2 items-center justify-between rounded-full bg-black px-1.5 shadow-md pointer-events-none">
|
||||
{/* Camera Lens */}
|
||||
<div className="flex h-1.5 w-1.5 items-center justify-center rounded-full bg-neutral-900 ring-1 ring-neutral-800">
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-[#1e3a8a]/70" />
|
||||
</div>
|
||||
{/* Speaker Earpiece Slit & Live Dot */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-0.5 w-1.5 rounded-full bg-neutral-800" />
|
||||
<span className="h-1 w-1 rounded-full bg-emerald-400 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Glass Glare Overlay */}
|
||||
<div className="pointer-events-none absolute inset-0 z-30 rounded-[24px] bg-gradient-to-tr from-transparent via-white/[0.06] to-transparent" />
|
||||
|
||||
{/* Phone Screen App Container */}
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={step.id} {...screenVariant} className="flex h-full flex-col">
|
||||
{SCREENS[step.id]()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, sub }: { title: string; sub?: string }) {
|
||||
function HomeBar() {
|
||||
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 className="py-1 flex justify-center items-center shrink-0 bg-transparent">
|
||||
<div className="h-1 w-20 rounded-full bg-neutral-900/25" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ w = "full", tone = "muted" }: { w?: string; tone?: "muted" | "primary" }) {
|
||||
function Header({ title, sub }: { title: string; sub?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`block h-2 rounded-full ${tone === "primary" ? "bg-primary/70" : "bg-on-surface-variant/25"}`}
|
||||
style={{ width: w }}
|
||||
/>
|
||||
<div className="bg-primary px-3.5 pb-2.5 pt-8 text-white shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display text-[12px] font-black tracking-tight flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-secondary" /> nearle
|
||||
</span>
|
||||
<span className="text-[7.5px] font-black uppercase tracking-widest bg-white/15 px-2 py-0.5 rounded-full border border-white/10">
|
||||
Merchant
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] font-black leading-snug">{title}</p>
|
||||
{sub && <p className="text-[9px] font-semibold opacity-85">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SCREENS: Record<MerchantStageId, () => React.ReactElement> = {
|
||||
join: () => (
|
||||
<>
|
||||
<div className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<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 className="flex flex-1 flex-col gap-2 px-3.5 pt-2.5">
|
||||
{[
|
||||
{ label: "Owner name", placeholder: "e.g. Rajesh Kumar" },
|
||||
{ label: "Shop name", placeholder: "e.g. Kumar Super Market" },
|
||||
{ label: "Neighbourhood", placeholder: "e.g. R.S. Puram" },
|
||||
].map((f) => (
|
||||
<div key={f.label} className="rounded-lg border border-primary/12 bg-white px-3 py-1.5 shadow-sm">
|
||||
<p className="text-[7.5px] font-black uppercase tracking-wide text-primary/80">{f.label}</p>
|
||||
<p className="text-[9.5px] font-semibold text-brand-dark mt-0.5">{f.placeholder}</p>
|
||||
</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">
|
||||
<div className="mt-auto mb-1 flex items-center justify-center gap-1.5 rounded-lg bg-primary py-2 text-[10.5px] font-black text-white shadow-sm">
|
||||
<MaterialIcon icon="handshake" size={13} /> Join Nearle
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</div>
|
||||
),
|
||||
digitize: () => (
|
||||
<>
|
||||
<Header title="Store is live" />
|
||||
<div className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="Store is live" sub="Searchable nearby" />
|
||||
<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 className="flex h-12 w-12 items-center justify-center rounded-full bg-secondary/15 text-secondary shadow-sm ring-4 ring-secondary/5">
|
||||
<MaterialIcon icon="check_circle" size={30} 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 className="mt-2.5 text-[12px] font-black text-brand-dark">Kumar Super Market</p>
|
||||
<p className="mt-1 text-[9px] font-medium text-on-surface-variant leading-tight max-w-[170px]">
|
||||
Your storefront & inventory are now live on Nearle.
|
||||
</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 className="mt-3 inline-flex items-center gap-1 rounded-md bg-secondary/10 px-2.5 py-1 text-[8px] font-black uppercase tracking-widest text-secondary border border-secondary/20">
|
||||
<MaterialIcon icon="qr_code_2" size={10} /> QR Verified
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</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">
|
||||
<div className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="Your catalogue" sub="18 items active" />
|
||||
<div className="grid flex-1 grid-cols-2 gap-2 px-3 pt-2.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" />
|
||||
{ emoji: "🥛", name: "Aavin Milk", price: "₹28" },
|
||||
{ emoji: "🍞", name: "Modern Bread", price: "₹40" },
|
||||
{ emoji: "🧈", name: "Amul Butter", price: "₹58" },
|
||||
{ emoji: "☕", name: "Nescafé Coffee", price: "₹120" },
|
||||
].map((item) => (
|
||||
<div key={item.name} className="rounded-lg border border-primary/10 bg-white p-2 shadow-sm flex flex-col justify-between">
|
||||
<div className="flex h-7 items-center justify-center rounded-md bg-primary/[0.06] text-sm">{item.emoji}</div>
|
||||
<div>
|
||||
<p className="mt-1 truncate text-[8.5px] font-black text-brand-dark">{item.name}</p>
|
||||
<p className="text-[8px] font-bold text-primary">{item.price}</p>
|
||||
</div>
|
||||
</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 className="mx-3 my-1 flex items-center justify-center gap-1 rounded-lg border border-primary/20 bg-white py-1.5 text-[9.5px] font-black text-primary shadow-sm">
|
||||
<MaterialIcon icon="add" size={12} /> Add products
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</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 className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="Customers nearby" sub="Live customer searches" />
|
||||
<div className="px-3 pt-2 pb-1.5">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-primary/15 bg-white px-2.5 py-1.5 shadow-sm">
|
||||
<MaterialIcon icon="search" size={12} className="text-primary/70" />
|
||||
<span className="text-[9.5px] font-bold text-brand-dark">Need breakfast items</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 className="flex-1 space-y-1.5 px-3">
|
||||
{[
|
||||
{ name: "Aavin Milk 500ml", dist: "300m" },
|
||||
{ name: "Sandwich Bread", dist: "300m" },
|
||||
{ name: "Table Butter 100g", dist: "300m" },
|
||||
].map((r) => (
|
||||
<div key={r.name} className="flex items-center justify-between rounded-lg border border-primary/8 bg-white px-2.5 py-1.5 shadow-sm">
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-brand-dark">{r.name}</p>
|
||||
<p className="text-[7.5px] font-semibold text-on-surface-variant/70">{r.dist} away</p>
|
||||
</div>
|
||||
<MaterialIcon icon="add_circle" size={14} 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>
|
||||
</>
|
||||
<p className="px-3 pt-1 text-[7.5px] font-bold text-on-surface-variant/70 text-center">3 shoppers viewing your store</p>
|
||||
<HomeBar />
|
||||
</div>
|
||||
),
|
||||
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 h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="New order received" sub="Order #1042 · Just now" />
|
||||
<div className="px-3 pt-2">
|
||||
<div className="rounded-xl border border-primary/15 bg-white p-2 shadow-sm">
|
||||
<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 className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<MaterialIcon icon="notifications_active" size={11} />
|
||||
</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" />
|
||||
<span className="text-[9.5px] font-black text-brand-dark">Customer #1042</span>
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-secondary animate-ping" />
|
||||
</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 className="mt-1.5 space-y-1">
|
||||
{[
|
||||
{ item: "Aavin Milk 500ml", qty: "× 1" },
|
||||
{ item: "Sandwich Bread", qty: "× 1" },
|
||||
{ item: "Farm Eggs (6 pcs)", qty: "× 1" },
|
||||
].map((i) => (
|
||||
<div key={i.item} className="flex items-center justify-between text-[8.5px] font-semibold text-on-surface-variant">
|
||||
<span className="truncate">{i.item}</span>
|
||||
<span className="text-primary font-bold ml-1">{i.qty}</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 className="mt-1.5 flex items-center justify-between border-t border-primary/10 pt-1">
|
||||
<span className="text-[8.5px] font-bold text-on-surface-variant">Total</span>
|
||||
<span className="font-display text-[12px] 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">
|
||||
<div className="mx-3 mt-auto mb-1 flex items-center justify-center gap-1.5 rounded-lg bg-primary py-2 text-[10.5px] font-black text-white shadow-sm">
|
||||
Accept order
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</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">
|
||||
<div className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="Pack order #1042" sub="2 of 3 items packed" />
|
||||
<div className="flex-1 space-y-1.5 px-3 pt-2.5">
|
||||
{[
|
||||
{ name: "Aavin Milk 500ml", done: true },
|
||||
{ name: "Sandwich Bread", done: true },
|
||||
{ name: "Farm Eggs (6 pcs)", done: false },
|
||||
].map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2 rounded-lg border border-primary/10 bg-white px-2.5 py-1.5 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"}`}
|
||||
className={`flex h-4.5 w-4.5 items-center justify-center rounded-md ${item.done ? "bg-secondary/15 text-secondary" : "border border-on-surface-variant/30 text-transparent"}`}
|
||||
>
|
||||
<MaterialIcon icon="check" size={12} />
|
||||
<MaterialIcon icon="check" size={11} />
|
||||
</span>
|
||||
<span className="text-[10px] font-black text-brand-dark">{n as string}</span>
|
||||
<span className="text-[9px] font-black text-brand-dark">{item.name}</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 className="mx-3 mb-1 flex items-center justify-center gap-1.5 rounded-lg bg-primary py-2 text-[10.5px] font-black text-white shadow-sm">
|
||||
<MaterialIcon icon="inventory_2" size={12} /> Mark packed
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</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]">
|
||||
<div className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="Out for delivery" sub="Nearle Rider assigned" />
|
||||
<div className="relative mx-3 mt-2 h-20 overflow-hidden rounded-xl 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 className="absolute left-1/2 top-1/2 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-white shadow-md">
|
||||
<MaterialIcon icon="two_wheeler" size={15} />
|
||||
</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="mx-3 mt-1.5 rounded-lg border border-primary/10 bg-white px-2.5 py-1.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>
|
||||
<span className="text-[9px] font-black text-brand-dark">Arriving in 8 min</span>
|
||||
<span className="rounded-md bg-secondary/12 px-2 py-0.5 text-[7.5px] font-black uppercase tracking-wide text-secondary border border-secondary/20">On the way</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</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
|
||||
<div className="flex h-full flex-col bg-[#FBF8FE]">
|
||||
<Header title="This week performance" sub="Revenue up 32% vs last week" />
|
||||
<div className="px-3.5 pt-2.5">
|
||||
<p className="font-display text-xl font-black text-brand-dark">₹48,200</p>
|
||||
<p className="flex items-center gap-1 text-[8.5px] font-black text-secondary">
|
||||
<MaterialIcon icon="trending_up" size={11} /> +32% vs last week
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-1 items-end gap-1.5 px-3.5 pb-4">
|
||||
<div className="mt-2 flex flex-1 items-end gap-1.5 px-3.5 pb-1.5">
|
||||
{[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"}`}
|
||||
className={`flex-1 rounded-t-sm transition-all ${i === 6 ? "bg-primary shadow-sm" : "bg-primary/25"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<HomeBar />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -5,45 +5,50 @@ 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.
|
||||
// Placement is anchored off the phone box's own edges rather than guessed
|
||||
// percentages: `right-full` puts a card's right edge exactly on the phone's
|
||||
// left edge, `left-full` the mirror. Plus a margin, that means a card can never
|
||||
// cover the live screen at any width — the demo always stays readable.
|
||||
// · lg+ : three cards drift in the left gutter at staggered heights, two in
|
||||
// the right gutter between them.
|
||||
// · below lg there is no gutter, so the two shortest cards sit clear above
|
||||
// and below the device instead of beside it.
|
||||
//
|
||||
// The longer labels are kept on the left, where the grid gap and the text
|
||||
// column's own slack leave more room to bleed into than the page margin does.
|
||||
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] },
|
||||
pos: "left-[-4%] top-[-8%] lg:left-auto lg:right-full lg:top-[7%] lg:mr-4",
|
||||
drift: { y: [-9, 9, -9], x: [0, 5, 0], rotate: [-1, 1, -1] },
|
||||
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] },
|
||||
text: "Order medicine",
|
||||
pos: "right-[-4%] bottom-[-7%] lg:bottom-auto lg:right-auto lg:left-full lg:top-[62%] lg:ml-4",
|
||||
drift: { y: [-10, 7, -10], x: [0, -5, 0], rotate: [1, -1, 1] },
|
||||
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] },
|
||||
emoji: "🔧",
|
||||
text: "Book an electrician",
|
||||
pos: "left-full top-[17%] ml-4",
|
||||
drift: { y: [8, -8, 8], x: [0, -6, 0], rotate: [-1.2, 0.8, -1.2] },
|
||||
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] },
|
||||
emoji: "🍅",
|
||||
text: "Need ingredients for biryani",
|
||||
pos: "right-full top-[42%] mr-4",
|
||||
drift: { y: [7, -10, 7], x: [0, 6, 0], rotate: [0.9, -1.1, 0.9] },
|
||||
duration: 20,
|
||||
delay: 0.7,
|
||||
always: false,
|
||||
@@ -51,8 +56,8 @@ const CARDS = [
|
||||
{
|
||||
emoji: "🥣",
|
||||
text: "Need breakfast for four",
|
||||
pos: "left-[-52%] top-[77%]",
|
||||
drift: { y: [9, -7, 9], x: [0, 4, 0] },
|
||||
pos: "right-full top-[77%] mr-4",
|
||||
drift: { y: [9, -7, 9], x: [0, 4, 0], rotate: [-0.8, 1.2, -0.8] },
|
||||
duration: 19,
|
||||
delay: 1,
|
||||
always: false,
|
||||
@@ -69,7 +74,7 @@ export function FloatingRequestCards() {
|
||||
{CARDS.map((card, i) => (
|
||||
<motion.div
|
||||
key={card.text}
|
||||
className={`absolute ${card.pos} ${card.always ? "" : "hidden xl:block"}`}
|
||||
className={`absolute ${card.pos} ${card.always ? "" : "hidden lg: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] }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { VoiceScreen, SCREEN_W, SCREEN_H } from "./VoiceScreen";
|
||||
import { useVoiceJourney } from "@/hooks/useVoiceJourney";
|
||||
import { useRealTimeClock } from "@/hooks/useRealTimeClock";
|
||||
|
||||
// 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
|
||||
@@ -21,6 +22,7 @@ const SCREEN = {
|
||||
export function PhoneStage({ className = "" }: { className?: string }) {
|
||||
const journey = useVoiceJourney();
|
||||
const { start, stop, reduced } = journey;
|
||||
const liveTime = useRealTimeClock();
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
@@ -57,72 +59,91 @@ export function PhoneStage({ className = "" }: { className?: string }) {
|
||||
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] }}
|
||||
className="pointer-events-none absolute left-1/2 bottom-[3%] h-[9%] w-[82%] -translate-x-1/2 rounded-[50%] bg-[#3B0764]/25 blur-2xl"
|
||||
// Shares the float's 7s period: the shadow tightens as the device
|
||||
// lifts, which is what sells the float as real height.
|
||||
animate={reduced ? undefined : { scaleX: [1, 0.88, 1], opacity: [0.5, 0.3, 0.5] }}
|
||||
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
{/* Float — vertical drift */}
|
||||
<motion.div
|
||||
animate={reduced ? undefined : { y: [-8, 8, -8] }}
|
||||
animate={reduced ? undefined : { y: [-10, 10, -10] }}
|
||||
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 */}
|
||||
{/* Pitch — a very slow rotateX on a third, co-prime period (13s vs the
|
||||
float's 7s and the sway's 11s), so the three never resolve into one
|
||||
visible 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" }}
|
||||
animate={reduced ? undefined : { rotateX: [1.2, -1.2, 1.2] }}
|
||||
transition={{ duration: 13, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{/* 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,
|
||||
}}
|
||||
{/* Sway — the device rests at rotateY -4°, so its left edge sits
|
||||
slightly forward under the root's 1600px perspective. The sway
|
||||
oscillates around that rest angle rather than around flat-on. */}
|
||||
<motion.div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: "476 / 941", transformStyle: "preserve-3d" }}
|
||||
animate={reduced ? { rotateY: -4 } : { rotateY: [-5.2, -2.8, -5.2] }}
|
||||
transition={{ duration: 11, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
{scale > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
height: SCREEN_H,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<VoiceScreen journey={journey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 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"
|
||||
/>
|
||||
{/* 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"
|
||||
/>
|
||||
|
||||
{/* Layer 3 — Real-Time Live Clock Overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-[18%] top-[2.85%] z-30 select-none text-[11px] font-black tracking-tight text-white sm:text-[12px]"
|
||||
>
|
||||
{liveTime || "9:41"}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
const showRequest =
|
||||
stage !== "launch" && !onHomeScreen && stage !== "listening" && stage !== "speaking";
|
||||
const fulfilmentSteps = isBooking ? SERVICE_FULFILMENT_STEPS : FULFILMENT_STEPS;
|
||||
const itemsSubtotal = request.items.reduce((sum, item) => sum + item.price, 0);
|
||||
// On the final beat every fulfilment step is done — nothing left pulsing.
|
||||
const fulfilDone = stage === "delivered" ? fulfilmentSteps.length : fulfilStep;
|
||||
|
||||
@@ -228,6 +229,20 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
</p>
|
||||
<AIThinking steps={AI_STEPS} activeStep={thinkingStep} tone="dark" />
|
||||
</div>
|
||||
|
||||
{/* Shops resolving underneath the reasoning. Deliberately
|
||||
contentless — they say "nearby stores are being checked"
|
||||
without ever claiming a name, and they set up the merchant
|
||||
card that lands on the next beat. */}
|
||||
<p className="mb-3 mt-6 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.18em] text-white/35">
|
||||
<MaterialIcon icon="travel_explore" size={13} />
|
||||
Scanning nearby
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<MerchantSkeleton key={i} index={i} reduced={reduced} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -235,7 +250,7 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
<motion.div
|
||||
key="merchant"
|
||||
{...screenMotion}
|
||||
className="absolute inset-0 flex h-full flex-col px-6 pt-4"
|
||||
className="absolute inset-0 flex h-full flex-col justify-center px-6 pb-8"
|
||||
>
|
||||
<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} />
|
||||
@@ -274,6 +289,33 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Why the AI settled on this one. Every figure is read
|
||||
straight off the request data — nothing is invented. */}
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ icon: "near_me", label: "Nearest", value: request.merchant.distance },
|
||||
{ icon: "star", label: "Rated", value: `${request.merchant.rating}` },
|
||||
{
|
||||
icon: "schedule",
|
||||
label: isBooking ? "Slot" : "Delivery",
|
||||
value: request.eta,
|
||||
},
|
||||
].map((fact) => (
|
||||
<div
|
||||
key={fact.label}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.05] px-2 py-3 text-center"
|
||||
>
|
||||
<MaterialIcon icon={fact.icon} size={15} className="text-[#C9A2FF]" />
|
||||
<p className="mt-1.5 truncate text-[11px] font-black leading-none">
|
||||
{fact.value}
|
||||
</p>
|
||||
<p className="mt-1 text-[8.5px] font-bold uppercase tracking-wider text-white/40">
|
||||
{fact.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -288,9 +330,22 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
{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 className="flex items-center gap-2.5 rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2.5">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[#A855F7]/20 text-[#C9A2FF]">
|
||||
<MaterialIcon icon={request.merchant.icon} size={16} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-black leading-tight">
|
||||
{request.merchant.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] font-semibold text-white/45">
|
||||
{request.merchant.area} · {request.merchant.distance}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 rounded-full bg-white/10 px-2 py-1 text-[9.5px] font-black text-white/80">
|
||||
<MaterialIcon icon="star" size={10} fill />
|
||||
{request.merchant.rating}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -324,8 +379,49 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Where it lands. Matches the location already shown in the
|
||||
app header, so the demo stays internally consistent.
|
||||
Short orders are the ones that used to leave the screen
|
||||
half empty; a four-item basket already fills it, and this
|
||||
row would push the confirm button off the bottom. */}
|
||||
{request.items.length < 3 && (
|
||||
<div className="mt-3 flex shrink-0 items-center gap-2.5 rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2.5">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/[0.07] text-white/70">
|
||||
<MaterialIcon icon={isBooking ? "home_work" : "location_on"} size={16} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[9px] font-bold uppercase tracking-wider text-white/40">
|
||||
{isBooking ? "Service at" : "Deliver to"}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11.5px] font-black leading-tight">
|
||||
Home · R.S. Puram
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Itemised, so a one-item request still fills the bill card.
|
||||
Items always sum to the request total — delivery is free,
|
||||
it is the merchant's own shop round the corner. */}
|
||||
<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">
|
||||
<div className="flex items-center justify-between text-[10.5px] font-bold text-white/45">
|
||||
<span>
|
||||
{request.items.length}{" "}
|
||||
{isBooking
|
||||
? request.items.length === 1
|
||||
? "service"
|
||||
: "services"
|
||||
: request.items.length === 1
|
||||
? "item"
|
||||
: "items"}
|
||||
</span>
|
||||
<span>₹{itemsSubtotal}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between text-[10.5px] font-bold text-white/45">
|
||||
<span>{isBooking ? "Booking fee" : "Delivery"}</span>
|
||||
<span className="font-black text-emerald-300">Free</span>
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-end justify-between border-t border-white/10 pt-2.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-white/50">
|
||||
Total
|
||||
</span>
|
||||
@@ -339,11 +435,13 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm auto-taps at the end of this beat */}
|
||||
{/* Confirm auto-taps at the end of this beat. mt-auto pins it
|
||||
to the foot of the screen the way a real checkout does, so
|
||||
a short order never leaves a hole under the bill. */}
|
||||
<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]"
|
||||
className="mb-1 mt-auto 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}
|
||||
@@ -467,6 +565,28 @@ export function VoiceScreen({ journey }: { journey: VoiceJourney }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder shop rows that pulse while the AI searches. Intentionally blank —
|
||||
// they read as "nearby stores are resolving" without ever asserting a name or a
|
||||
// figure the demo hasn't earned yet.
|
||||
function MerchantSkeleton({ index, reduced }: { index: number; reduced: boolean }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/[0.07] bg-white/[0.04] px-3 py-2.5"
|
||||
animate={reduced ? undefined : { opacity: [0.4, 0.75, 0.4] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut", delay: index * 0.22 }}
|
||||
>
|
||||
<span className="h-9 w-9 shrink-0 rounded-xl bg-white/[0.07]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block h-2.5 rounded-full bg-white/[0.09]"
|
||||
style={{ width: `${72 - index * 14}%` }}
|
||||
/>
|
||||
<span className="mt-1.5 block h-2 w-[38%] rounded-full bg-white/[0.06]" />
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// The listening orb: a soft purple core with breathing rings.
|
||||
function MicOrb({ reduced, active }: { reduced: boolean; active: boolean }) {
|
||||
return (
|
||||
|
||||
@@ -7,10 +7,13 @@ import { motion, useReducedMotion } from "framer-motion";
|
||||
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";
|
||||
|
||||
// Opacities are tuned for a pure white canvas. On the old lavender background
|
||||
// these read as texture; on white anything stronger reads as clutter and pulls
|
||||
// attention off the phone.
|
||||
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 },
|
||||
{ y: 0, scaleY: 1, opacity: 0.1, duration: 12, width: 2 },
|
||||
{ y: 26, scaleY: 0.62, opacity: 0.07, duration: 16, width: 1.5 },
|
||||
{ y: -22, scaleY: 1.35, opacity: 0.055, duration: 21, width: 1.5 },
|
||||
];
|
||||
|
||||
// Elegant voice waves behind the phone. Purely decorative, low opacity, and
|
||||
|
||||
264
src/components/ui/BorderGlow.tsx
Normal file
264
src/components/ui/BorderGlow.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/* React Bits — BorderGlow (TS + Tailwind variant), vendored verbatim from
|
||||
* https://reactbits.dev/components/border-glow
|
||||
* Source: DavidHDev/react-bits · src/ts-tailwind/Components/BorderGlow/BorderGlow.tsx
|
||||
*
|
||||
* Kept unmodified so it can be re-synced from upstream. The intro sweep drives
|
||||
* its own rAF loop from an effect, which trips the project's set-state-in-effect
|
||||
* rule; disabled here rather than patching third-party code.
|
||||
*/
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useRef, useCallback, useState, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface BorderGlowProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
edgeSensitivity?: number;
|
||||
glowColor?: string;
|
||||
backgroundColor?: string;
|
||||
borderRadius?: number;
|
||||
glowRadius?: number;
|
||||
glowIntensity?: number;
|
||||
coneSpread?: number;
|
||||
animated?: boolean;
|
||||
colors?: string[];
|
||||
fillOpacity?: number;
|
||||
}
|
||||
|
||||
function parseHSL(hslStr: string): { h: number; s: number; l: number } {
|
||||
const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/);
|
||||
if (!match) return { h: 40, s: 80, l: 80 };
|
||||
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
|
||||
}
|
||||
|
||||
function buildBoxShadow(glowColor: string, intensity: number): string {
|
||||
const { h, s, l } = parseHSL(glowColor);
|
||||
const base = `${h}deg ${s}% ${l}%`;
|
||||
const layers: [number, number, number, number, number, boolean][] = [
|
||||
[0, 0, 0, 1, 100, true], [0, 0, 1, 0, 60, true], [0, 0, 3, 0, 50, true],
|
||||
[0, 0, 6, 0, 40, true], [0, 0, 15, 0, 30, true], [0, 0, 25, 2, 20, true],
|
||||
[0, 0, 50, 2, 10, true],
|
||||
[0, 0, 1, 0, 60, false], [0, 0, 3, 0, 50, false], [0, 0, 6, 0, 40, false],
|
||||
[0, 0, 15, 0, 30, false], [0, 0, 25, 2, 20, false], [0, 0, 50, 2, 10, false],
|
||||
];
|
||||
return layers.map(([x, y, blur, spread, alpha, inset]) => {
|
||||
const a = Math.min(alpha * intensity, 100);
|
||||
return `${inset ? 'inset ' : ''}${x}px ${y}px ${blur}px ${spread}px hsl(${base} / ${a}%)`;
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); }
|
||||
function easeInCubic(x: number) { return x * x * x; }
|
||||
|
||||
interface AnimateOpts {
|
||||
start?: number; end?: number; duration?: number; delay?: number;
|
||||
ease?: (t: number) => number; onUpdate: (v: number) => void; onEnd?: () => void;
|
||||
}
|
||||
|
||||
function animateValue({ start = 0, end = 100, duration = 1000, delay = 0, ease = easeOutCubic, onUpdate, onEnd }: AnimateOpts) {
|
||||
const t0 = performance.now() + delay;
|
||||
function tick() {
|
||||
const elapsed = performance.now() - t0;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
onUpdate(start + (end - start) * ease(t));
|
||||
if (t < 1) requestAnimationFrame(tick);
|
||||
else if (onEnd) onEnd();
|
||||
}
|
||||
setTimeout(() => requestAnimationFrame(tick), delay);
|
||||
}
|
||||
|
||||
const GRADIENT_POSITIONS = ['80% 55%', '69% 34%', '8% 6%', '41% 38%', '86% 85%', '82% 18%', '51% 4%'];
|
||||
const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1];
|
||||
|
||||
function buildMeshGradients(colors: string[]): string[] {
|
||||
const gradients: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)];
|
||||
gradients.push(`radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`);
|
||||
}
|
||||
gradients.push(`linear-gradient(${colors[0]} 0 100%)`);
|
||||
return gradients;
|
||||
}
|
||||
|
||||
const BorderGlow: React.FC<BorderGlowProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
edgeSensitivity = 30,
|
||||
glowColor = '40 80 80',
|
||||
backgroundColor = '#120F17',
|
||||
borderRadius = 28,
|
||||
glowRadius = 40,
|
||||
glowIntensity = 1.0,
|
||||
coneSpread = 25,
|
||||
animated = false,
|
||||
colors = ['#c084fc', '#f472b6', '#38bdf8'],
|
||||
fillOpacity = 0.5,
|
||||
}) => {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [cursorAngle, setCursorAngle] = useState(45);
|
||||
const [edgeProximity, setEdgeProximity] = useState(0);
|
||||
const [sweepActive, setSweepActive] = useState(false);
|
||||
|
||||
const getCenterOfElement = useCallback((el: HTMLElement) => {
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
return [width / 2, height / 2];
|
||||
}, []);
|
||||
|
||||
const getEdgeProximity = useCallback((el: HTMLElement, x: number, y: number) => {
|
||||
const [cx, cy] = getCenterOfElement(el);
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
let kx = Infinity;
|
||||
let ky = Infinity;
|
||||
if (dx !== 0) kx = cx / Math.abs(dx);
|
||||
if (dy !== 0) ky = cy / Math.abs(dy);
|
||||
return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1);
|
||||
}, [getCenterOfElement]);
|
||||
|
||||
const getCursorAngle = useCallback((el: HTMLElement, x: number, y: number) => {
|
||||
const [cx, cy] = getCenterOfElement(el);
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
if (dx === 0 && dy === 0) return 0;
|
||||
const radians = Math.atan2(dy, dx);
|
||||
let degrees = radians * (180 / Math.PI) + 90;
|
||||
if (degrees < 0) degrees += 360;
|
||||
return degrees;
|
||||
}, [getCenterOfElement]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
setEdgeProximity(getEdgeProximity(card, x, y));
|
||||
setCursorAngle(getCursorAngle(card, x, y));
|
||||
}, [getEdgeProximity, getCursorAngle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) return;
|
||||
const angleStart = 110;
|
||||
const angleEnd = 465;
|
||||
setSweepActive(true);
|
||||
setCursorAngle(angleStart);
|
||||
|
||||
animateValue({ duration: 500, onUpdate: v => setEdgeProximity(v / 100) });
|
||||
animateValue({ ease: easeInCubic, duration: 1500, end: 50, onUpdate: v => {
|
||||
setCursorAngle((angleEnd - angleStart) * (v / 100) + angleStart);
|
||||
}});
|
||||
animateValue({ ease: easeOutCubic, delay: 1500, duration: 2250, start: 50, end: 100, onUpdate: v => {
|
||||
setCursorAngle((angleEnd - angleStart) * (v / 100) + angleStart);
|
||||
}});
|
||||
animateValue({ ease: easeInCubic, delay: 2500, duration: 1500, start: 100, end: 0,
|
||||
onUpdate: v => setEdgeProximity(v / 100),
|
||||
onEnd: () => setSweepActive(false),
|
||||
});
|
||||
}, [animated]);
|
||||
|
||||
const colorSensitivity = edgeSensitivity + 20;
|
||||
const isVisible = isHovered || sweepActive;
|
||||
const borderOpacity = isVisible
|
||||
? Math.max(0, (edgeProximity * 100 - colorSensitivity) / (100 - colorSensitivity))
|
||||
: 0;
|
||||
const glowOpacity = isVisible
|
||||
? Math.max(0, (edgeProximity * 100 - edgeSensitivity) / (100 - edgeSensitivity))
|
||||
: 0;
|
||||
|
||||
const meshGradients = buildMeshGradients(colors);
|
||||
const borderBg = meshGradients.map(g => `${g} border-box`);
|
||||
const fillBg = meshGradients.map(g => `${g} padding-box`);
|
||||
const angleDeg = `${cursorAngle.toFixed(3)}deg`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
className={`relative grid isolate border border-white/15 ${className}`}
|
||||
style={{
|
||||
background: backgroundColor,
|
||||
borderRadius: `${borderRadius}px`,
|
||||
transform: 'translate3d(0, 0, 0.01px)',
|
||||
boxShadow: 'rgba(0,0,0,0.1) 0 1px 2px, rgba(0,0,0,0.1) 0 2px 4px, rgba(0,0,0,0.1) 0 4px 8px, rgba(0,0,0,0.1) 0 8px 16px, rgba(0,0,0,0.1) 0 16px 32px, rgba(0,0,0,0.1) 0 32px 64px',
|
||||
}}
|
||||
>
|
||||
{/* mesh gradient border */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit] -z-[1]"
|
||||
style={{
|
||||
border: '1px solid transparent',
|
||||
background: [
|
||||
`linear-gradient(${backgroundColor} 0 100%) padding-box`,
|
||||
'linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box',
|
||||
...borderBg,
|
||||
].join(', '),
|
||||
opacity: borderOpacity,
|
||||
maskImage: `conic-gradient(from ${angleDeg} at center, black ${coneSpread}%, transparent ${coneSpread + 15}%, transparent ${100 - coneSpread - 15}%, black ${100 - coneSpread}%)`,
|
||||
WebkitMaskImage: `conic-gradient(from ${angleDeg} at center, black ${coneSpread}%, transparent ${coneSpread + 15}%, transparent ${100 - coneSpread - 15}%, black ${100 - coneSpread}%)`,
|
||||
transition: isVisible ? 'opacity 0.25s ease-out' : 'opacity 0.75s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* mesh gradient fill near edges */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit] -z-[1]"
|
||||
style={{
|
||||
border: '1px solid transparent',
|
||||
background: fillBg.join(', '),
|
||||
maskImage: [
|
||||
'linear-gradient(to bottom, black, black)',
|
||||
'radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)',
|
||||
'radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)',
|
||||
'radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)',
|
||||
'radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)',
|
||||
'radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)',
|
||||
`conic-gradient(from ${angleDeg} at center, transparent 5%, black 15%, black 85%, transparent 95%)`,
|
||||
].join(', '),
|
||||
WebkitMaskImage: [
|
||||
'linear-gradient(to bottom, black, black)',
|
||||
'radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)',
|
||||
'radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)',
|
||||
'radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)',
|
||||
'radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)',
|
||||
'radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)',
|
||||
`conic-gradient(from ${angleDeg} at center, transparent 5%, black 15%, black 85%, transparent 95%)`,
|
||||
].join(', '),
|
||||
maskComposite: 'subtract, add, add, add, add, add',
|
||||
WebkitMaskComposite: 'source-out, source-over, source-over, source-over, source-over, source-over',
|
||||
opacity: borderOpacity * fillOpacity,
|
||||
mixBlendMode: 'soft-light',
|
||||
transition: isVisible ? 'opacity 0.25s ease-out' : 'opacity 0.75s ease-in-out',
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* outer glow */}
|
||||
<span
|
||||
className="absolute pointer-events-none z-[1] rounded-[inherit]"
|
||||
style={{
|
||||
inset: `${-glowRadius}px`,
|
||||
maskImage: `conic-gradient(from ${angleDeg} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`,
|
||||
WebkitMaskImage: `conic-gradient(from ${angleDeg} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`,
|
||||
opacity: glowOpacity,
|
||||
mixBlendMode: 'plus-lighter',
|
||||
transition: isVisible ? 'opacity 0.25s ease-out' : 'opacity 0.75s ease-in-out',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<span
|
||||
className="absolute rounded-[inherit]"
|
||||
style={{
|
||||
inset: `${glowRadius}px`,
|
||||
boxShadow: buildBoxShadow(glowColor, glowIntensity),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col relative overflow-auto z-[1]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BorderGlow;
|
||||
52
src/components/ui/MagneticButton.tsx
Normal file
52
src/components/ui/MagneticButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { motion, useMotionValue, useSpring, useReducedMotion } from "framer-motion";
|
||||
|
||||
type MagneticButtonProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** How far the content drifts toward the cursor, as a fraction of the offset. */
|
||||
strength?: number;
|
||||
};
|
||||
|
||||
/* React Bits — Magnetic Button, rebuilt on Framer Motion springs so it shares
|
||||
* the easing feel of the rest of the Nearle motion system. Translation only, so
|
||||
* it stays on the compositor; disabled under prefers-reduced-motion. */
|
||||
export function MagneticButton({
|
||||
children,
|
||||
className = "",
|
||||
strength = 0.3,
|
||||
}: MagneticButtonProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const springX = useSpring(x, { stiffness: 260, damping: 20, mass: 0.4 });
|
||||
const springY = useSpring(y, { stiffness: 260, damping: 20, mass: 0.4 });
|
||||
|
||||
function handleMove(e: PointerEvent<HTMLSpanElement>) {
|
||||
const el = ref.current;
|
||||
if (!el || reduce) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
x.set((e.clientX - rect.left - rect.width / 2) * strength);
|
||||
y.set((e.clientY - rect.top - rect.height / 2) * strength);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={ref}
|
||||
onPointerMove={handleMove}
|
||||
onPointerLeave={reset}
|
||||
style={reduce ? undefined : { x: springX, y: springY }}
|
||||
className={`inline-flex ${className}`}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
52
src/components/ui/Spotlight.tsx
Normal file
52
src/components/ui/Spotlight.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReducedMotion } from "framer-motion";
|
||||
|
||||
type SpotlightProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Diameter of the cursor wash, in px. */
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
/* React Bits — Spotlight. The pointer position is written straight to CSS
|
||||
* custom properties, so the wash tracks at pointer rate without re-rendering
|
||||
* React. The wrapper owns the card surface: it carries the background and
|
||||
* radius, and the wash sits between that background and the content via a
|
||||
* negative z-index inside the wrapper's own stacking context. */
|
||||
export function Spotlight({
|
||||
children,
|
||||
className = "",
|
||||
size = 360,
|
||||
color = "rgba(168,85,247,0.16)",
|
||||
}: SpotlightProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
function handleMove(e: PointerEvent<HTMLDivElement>) {
|
||||
const el = ref.current;
|
||||
if (!el || reduce) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.setProperty("--spot-x", `${e.clientX - rect.left}px`);
|
||||
el.style.setProperty("--spot-y", `${e.clientY - rect.top}px`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onPointerMove={handleMove}
|
||||
className={`group/spot relative isolate ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-[1] rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover/spot:opacity-100"
|
||||
style={{
|
||||
background: `radial-gradient(${size}px circle at var(--spot-x, 50%) var(--spot-y, 0px), ${color}, transparent 72%)`,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function WhyNearleHero() {
|
||||
animate="visible"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-bold tracking-widest bg-white text-[#6330D6] shadow-md uppercase mb-6 w-fit"
|
||||
>
|
||||
What is AI Commerce?
|
||||
What is AI Commerce
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
[
|
||||
{ "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" }
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
[
|
||||
{ "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" }
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{ "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" }
|
||||
]
|
||||
@@ -116,9 +116,10 @@ export function useMerchantJourney(): MerchantJourney {
|
||||
|
||||
const [started, setStarted] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [autoplaying, setAutoplaying] = useState(false);
|
||||
const [autoplaying, setAutoplaying] = useState(true);
|
||||
|
||||
const last = MERCHANT_STEPS.length - 1;
|
||||
const total = MERCHANT_STEPS.length;
|
||||
const last = total - 1;
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clear = useCallback(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
@@ -126,50 +127,49 @@ export function useMerchantJourney(): MerchantJourney {
|
||||
}, []);
|
||||
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.
|
||||
// Continuously advance to next slide every 3.2s
|
||||
useEffect(() => {
|
||||
if (!started || reduced || !autoplaying || index >= last) return;
|
||||
timer.current = setTimeout(() => setIndex((i) => Math.min(i + 1, last)), AUTOPLAY_MS);
|
||||
if (reduced || !autoplaying) return;
|
||||
timer.current = setTimeout(() => {
|
||||
setIndex((i) => (i + 1) % total);
|
||||
}, AUTOPLAY_MS);
|
||||
return clear;
|
||||
}, [started, reduced, autoplaying, index, last, clear]);
|
||||
}, [reduced, autoplaying, index, total, clear]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setStarted((was) => {
|
||||
if (!was && !reduced) setAutoplaying(true);
|
||||
return true;
|
||||
});
|
||||
setStarted(true);
|
||||
if (!reduced) setAutoplaying(true);
|
||||
}, [reduced]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
clear();
|
||||
setAutoplaying(false);
|
||||
setStarted(true);
|
||||
setIndex(Math.max(0, Math.min(i, last)));
|
||||
if (!reduced) setAutoplaying(true);
|
||||
},
|
||||
[clear, last]
|
||||
[clear, last, reduced]
|
||||
);
|
||||
|
||||
const next = useCallback(() => {
|
||||
clear();
|
||||
setAutoplaying(false);
|
||||
setStarted(true);
|
||||
setIndex((i) => Math.min(i + 1, last));
|
||||
}, [clear, last]);
|
||||
setIndex((i) => (i + 1) % total);
|
||||
if (!reduced) setAutoplaying(true);
|
||||
}, [clear, total, reduced]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
clear();
|
||||
setAutoplaying(false);
|
||||
setStarted(true);
|
||||
setIndex((i) => Math.max(i - 1, 0));
|
||||
}, [clear]);
|
||||
setIndex((i) => (i - 1 + total) % total);
|
||||
if (!reduced) setAutoplaying(true);
|
||||
}, [clear, total, reduced]);
|
||||
|
||||
const replay = useCallback(() => {
|
||||
clear();
|
||||
setIndex(0);
|
||||
setStarted(true);
|
||||
setAutoplaying(!reduced);
|
||||
if (!reduced) setAutoplaying(true);
|
||||
}, [clear, reduced]);
|
||||
|
||||
return {
|
||||
|
||||
23
src/hooks/useRealTimeClock.ts
Normal file
23
src/hooks/useRealTimeClock.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useRealTimeClock(): string {
|
||||
const [timeString, setTimeString] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const updateClock = () => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const formattedMins = minutes < 10 ? `0${minutes}` : minutes;
|
||||
setTimeString(`${hours}:${formattedMins}`);
|
||||
};
|
||||
|
||||
updateClock();
|
||||
const interval = setInterval(updateClock, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return timeString;
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
@@ -1,56 +1,11 @@
|
||||
// Shared types for the "Digital Neighbourhood Store" homepage journey.
|
||||
// Shared types for the homepage voice commerce demo.
|
||||
// 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.
|
||||
// ─── Voice commerce (homepage product demo) ───
|
||||
// What Nearle AI resolves a spoken request into: 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";
|
||||
|
||||
Reference in New Issue
Block a user