update home page cards design changes

This commit is contained in:
2026-08-03 11:01:22 +05:30
parent 1d819127ca
commit 22508e3bc0
3 changed files with 446 additions and 145 deletions

View File

@@ -0,0 +1,324 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
// Divider position, in percent of card width. The top (Nearle) layer is clipped
// from the left by this value, so 0 = all Nearle, 100 = all Traditional.
const REST_POSITION = 50;
// One-shot hint played on scroll-in: sweep out to 20, across to 80, settle back
// at centre. Purely to teach the interaction — cancelled the moment the user
// touches the card.
const HINT_STOPS = [20, 80, REST_POSITION];
const HINT_LEG_MS = 820;
const HINT_DELAY_MS = 480;
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: "chat_bubble", text: "Just ask in your own words" },
{ icon: "storefront", text: "The shop you trust stays at the centre" },
{ icon: "near_me", text: "You discover the shops right around you" },
{ icon: "handshake", text: "Local shops grow — they never get replaced" },
];
const clamp = (n: number) => Math.min(100, Math.max(0, n));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
type PanelProps = {
/** Renders the panel in normal flow, invisibly, purely to give the card its height. */
ghost?: boolean;
};
// The two layers sit on top of each other, so a horizontal wipe only reads as a
// swap if each side keeps its content on its own side of the divider:
// Traditional hugs the left edge, Nearle mirrors to the right. Rows never wrap —
// the divider, not a line break, is what truncates them.
const ROW_BASE = "flex items-center gap-3 overflow-hidden rounded-2xl p-3 sm:p-3.5";
const TEXT_BASE = "min-w-0 flex-1 truncate text-[13px] font-medium leading-snug sm:text-[15px]";
function TraditionalPanel({ ghost }: PanelProps) {
return (
<div
className={`flex flex-col p-5 sm:p-8 md:p-10 ${
ghost ? "invisible" : "absolute inset-0 bg-[#F2F5F8]"
}`}
aria-hidden={ghost}
>
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 sm:h-11 sm:w-11 items-center justify-center rounded-2xl bg-white text-[#7A8B9A] shadow-sm">
<MaterialIcon icon="apartment" size={22} />
</span>
<div>
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-[#94A3B1]">Today</p>
<h3 className="font-display text-[15px] font-black tracking-tight sm:text-xl text-[#0A2540]/70 ">
Traditional Commerce
</h3>
</div>
</div>
<ul className="mt-6 space-y-2.5 sm:mt-7 sm:space-y-3">
{TRADITIONAL.map((item) => (
<li key={item.text} className={`${ROW_BASE} border border-[#0A2540]/6 bg-white/70`}>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#E7ECF1] text-[#8296A6]">
<MaterialIcon icon={item.icon} size={18} />
</span>
<span className={`${TEXT_BASE} text-[#5A6B7B]`}>{item.text}</span>
</li>
))}
</ul>
</div>
);
}
function NearlePanel({ ghost }: PanelProps) {
return (
<div
className={`flex flex-col p-5 sm:p-8 md:p-10 ${
ghost
? "invisible"
: "absolute inset-0 bg-[linear-gradient(135deg,#FFFFFF_0%,#F3FDFC_52%,#DFF7F5_100%)]"
}`}
aria-hidden={ghost}
>
<div className="flex flex-row-reverse items-center gap-3">
<span className="flex h-9 w-9 shrink-0 sm:h-11 sm:w-11 items-center justify-center rounded-2xl bg-[#00D4C8]/12 text-[#00A79E] ring-1 ring-[#00D4C8]/25">
<MaterialIcon icon="auto_awesome" size={22} />
</span>
<div className="text-right">
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-[#00A79E]">Nearle</p>
<h3 className="font-display text-[15px] font-black tracking-tight sm:text-xl text-[#0A2540]">
Nearle AI Commerce
</h3>
</div>
</div>
<ul className="mt-6 space-y-2.5 sm:mt-7 sm:space-y-3">
{NEARLE.map((item) => (
<li
key={item.text}
className={`${ROW_BASE} flex-row-reverse border border-[#00D4C8]/20 bg-white/80 shadow-[0_2px_10px_rgba(10,37,64,0.04)] backdrop-blur-sm`}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#00D4C8]/12 text-[#00A79E]">
<MaterialIcon icon={item.icon} size={18} />
</span>
<span className={`${TEXT_BASE} text-right font-bold text-[#0A2540]`}>{item.text}</span>
</li>
))}
</ul>
</div>
);
}
export function CommerceCompareSlider() {
const reduce = useReducedMotion();
const wrapRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const inView = useInView(wrapRef, { once: true, margin: "-60px" });
const [position, setPosition] = useState(REST_POSITION);
const [dragging, setDragging] = useState(false);
// Hint bookkeeping: a rAF id to cancel, and a latch so it never replays.
const hintFrame = useRef<number | null>(null);
const hintTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hintDone = useRef(false);
const stopHint = useCallback(() => {
hintDone.current = true;
if (hintFrame.current !== null) cancelAnimationFrame(hintFrame.current);
if (hintTimer.current !== null) clearTimeout(hintTimer.current);
hintFrame.current = null;
hintTimer.current = null;
}, []);
const setFromClientX = useCallback((clientX: number) => {
const rect = cardRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
setPosition(clamp(((clientX - rect.left) / rect.width) * 100));
}, []);
// ── One-shot hint ──────────────────────────────────────────────────────
// Hand-rolled rAF tween rather than Framer Motion: this component reserves
// Framer Motion for the entrance only, and the divider is driven by the same
// numeric state the pointer handlers write to.
useEffect(() => {
if (!inView || reduce || hintDone.current) return;
let leg = 0;
let from = REST_POSITION;
let start = 0;
const step = (now: number) => {
if (!start) start = now;
const t = Math.min(1, (now - start) / HINT_LEG_MS);
const to = HINT_STOPS[leg];
setPosition(from + (to - from) * easeInOut(t));
if (t < 1) {
hintFrame.current = requestAnimationFrame(step);
return;
}
leg += 1;
if (leg >= HINT_STOPS.length) {
stopHint();
return;
}
from = to;
start = 0;
hintFrame.current = requestAnimationFrame(step);
};
hintTimer.current = setTimeout(() => {
hintFrame.current = requestAnimationFrame(step);
}, HINT_DELAY_MS);
return () => {
if (hintFrame.current !== null) cancelAnimationFrame(hintFrame.current);
if (hintTimer.current !== null) clearTimeout(hintTimer.current);
};
}, [inView, reduce, stopHint]);
// ── Mouse ──────────────────────────────────────────────────────────────
const onMouseDown = (e: ReactMouseEvent) => {
stopHint();
setDragging(true);
setFromClientX(e.clientX);
};
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => setFromClientX(e.clientX);
const onUp = () => setDragging(false);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [dragging, setFromClientX]);
// ── Touch ──────────────────────────────────────────────────────────────
// touch-action: pan-y keeps vertical page scrolling native; horizontal
// movement over the card is ours.
const onTouchStart = (e: ReactTouchEvent) => {
stopHint();
setDragging(true);
setFromClientX(e.touches[0].clientX);
};
const onTouchMove = (e: ReactTouchEvent) => setFromClientX(e.touches[0].clientX);
const onTouchEnd = () => setDragging(false);
// ── Keyboard ───────────────────────────────────────────────────────────
const onKeyDown = (e: ReactKeyboardEvent) => {
const step = e.shiftKey ? 10 : 4;
if (e.key === "ArrowLeft") {
stopHint();
setPosition((p) => clamp(p - step));
} else if (e.key === "ArrowRight") {
stopHint();
setPosition((p) => clamp(p + step));
} else if (e.key === "Home") {
stopHint();
setPosition(0);
} else if (e.key === "End") {
stopHint();
setPosition(100);
} else {
return;
}
e.preventDefault();
};
return (
<motion.div
ref={wrapRef}
initial={{ opacity: 0, y: 28 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="mt-14"
>
<div
ref={cardRef}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
className="relative min-h-[320px] select-none overflow-hidden rounded-[32px] border border-[#0A2540]/8 bg-white shadow-[0_10px_30px_rgba(10,37,64,0.06)] md:min-h-[400px]"
style={{ touchAction: "pan-y", cursor: dragging ? "grabbing" : "ew-resize" }}
>
{/* Invisible in-flow copy: gives the card a real height so neither
absolutely-positioned layer can ever be clipped on small screens. */}
<NearlePanel ghost />
{/* Bottom layer — the world as it works today. */}
<TraditionalPanel />
{/* Top layer — clipped from the left by the divider. */}
<div
className="absolute inset-0"
style={{
clipPath: `inset(0 0 0 ${position}%)`,
willChange: "clip-path",
}}
>
<NearlePanel />
</div>
{/* Seam: an opaque strip under the divider so the two halves never read
as one run-on sentence when the rows meet mid-word. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-[11px] -translate-x-1/2 bg-white/80 backdrop-blur-[2px] sm:w-[7px]"
style={{ left: `${position}%` }}
/>
{/* Divider line */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-[3px] -translate-x-1/2 bg-[linear-gradient(180deg,#00D4C8_0%,#FF6B35_100%)] shadow-[0_0_18px_rgba(0,212,200,0.45)]"
style={{ left: `${position}%` }}
/>
{/* Handle */}
<div
role="slider"
tabIndex={0}
aria-label="Compare traditional commerce with Nearle AI commerce"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(100 - position)}
aria-valuetext={`${Math.round(100 - position)}% Nearle AI Commerce`}
onKeyDown={onKeyDown}
className="absolute top-1/2 flex h-12 w-12 items-center justify-center rounded-full border border-[#0A2540]/8 bg-white text-[#FF6B35] shadow-[0_6px_20px_rgba(10,37,64,0.18)] outline-none transition-transform duration-200 focus-visible:ring-2 focus-visible:ring-[#00D4C8] focus-visible:ring-offset-2"
style={{
left: `${position}%`,
cursor: dragging ? "grabbing" : "grab",
transform: `translate(-50%, -50%) scale(${dragging ? 1.08 : 1})`,
}}
>
<MaterialIcon icon="swap_horiz" size={24} />
</div>
</div>
<motion.p
initial={{ opacity: 0 }}
animate={inView ? { opacity: 1 } : undefined}
transition={{ duration: 0.5, delay: 0.35, ease: "easeOut" }}
className="mt-4 flex items-center justify-center gap-1.5 text-[13px] font-medium text-[#7A8B9A]"
>
<MaterialIcon icon="swap_horiz" size={16} />
Drag to compare
</motion.p>
</motion.div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
interface DifferencePillar {
number: string;
icon: string;
title: string;
description: string;
traditional: string;
nearle: string;
}
const PILLARS: DifferencePillar[] = [
{
number: "01",
icon: "search",
title: "Ask in Natural Words",
description: "Type or speak like you're talking to a neighbour — 'Who has fresh sourdough near me?' to see live local store stock.",
traditional: "Paid ads & dark warehouses",
nearle: "Real local shop inventory",
},
{
number: "02",
icon: "storefront",
title: "Your Store at the Centre",
description: "Your favourite neighbourhood grocery, bakery, or chemist gets its own digital storefront and QR ordering.",
traditional: "Faceless dark warehouses",
nearle: "The shop you already trust",
},
{
number: "03",
icon: "sell",
title: "Direct Store Prices",
description: "Order directly from your local merchant with zero middleman app commission markups.",
traditional: "25–30% extra app markup",
nearle: "Direct prices & 1-tap reorders",
},
{
number: "04",
icon: "favorite",
title: "Local Stores Grow",
description: "Every purchase keeps local store owners thriving, preserving neighbourhood jobs and community culture.",
traditional: "Money leaves neighbourhood",
nearle: "100% supports local stores",
},
];
const EASE = [0.22, 1, 0.36, 1] as const;
export function CommerceFlipCard() {
const wrapRef = useRef<HTMLDivElement>(null);
const inView = useInView(wrapRef, { once: true, margin: "-50px" });
return (
<motion.div
ref={wrapRef}
initial={{ opacity: 0, y: 24 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 0.5, ease: EASE }}
className="mt-10 lg:mt-12 w-full max-w-[1240px] mx-auto"
>
{/* ─── 4-Card Grid matching Nearle Identity ─── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 lg:gap-6 items-stretch">
{PILLARS.map((item) => (
<div
key={item.number}
className="group relative flex flex-col justify-between rounded-[28px] border border-[rgba(99,48,214,0.12)] bg-white p-6 md:p-7 shadow-[0_16px_40px_rgba(22,0,29,0.06)] hover:shadow-[0_24px_55px_rgba(99,48,214,0.14)] hover:-translate-y-1 transition-all duration-300"
>
{/* Top Row: Icon + Number */}
<div>
<div className="flex items-center justify-between mb-5">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#6330D6]/8 text-[#6330D6] group-hover:bg-[#6330D6] group-hover:text-white transition-colors duration-300 shadow-xs">
<MaterialIcon icon={item.icon} size={24} />
</div>
<span className="font-display text-4xl font-black text-[#6330D6]/15 group-hover:text-[#6330D6]/25 transition-colors select-none">
{item.number}
</span>
</div>
{/* Title & Description */}
<h3 className="font-display text-lg lg:text-xl font-black text-[#16001D] leading-tight">
{item.title}
</h3>
<p className="mt-2.5 text-[13px] leading-relaxed text-[#5E5866] font-medium">
{item.description}
</p>
</div>
{/* Bottom Comparison Pill */}
<div className="mt-6 pt-4 border-t border-[#6330D6]/10 space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-rose-100 text-rose-600 font-bold text-[9px]">
✕
</span>
<span className="truncate">{item.traditional}</span>
</div>
<div className="flex items-center gap-2 text-[11px] font-bold text-[#008B82]">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#00D4C8]/25 text-[#007D76] font-bold text-[9px]">
✓
</span>
<span className="truncate">{item.nearle}</span>
</div>
</div>
</div>
))}
</div>
</motion.div>
);
}

View File

@@ -2,29 +2,8 @@
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: "Just ask in your own words" },
{ icon: "storefront", text: "The shop you trust stays at the centre" },
{ icon: "near_me", text: "You discover the shops right around you" },
{ icon: "handshake", text: "Local shops grow — they never get replaced" },
];
import { CommerceFlipCard } from "@/components/home/CommerceFlipCard";
import { fadeUp, staggerContainer, viewport } from "@/lib/animations";
// The gentle S-curve the transformation flow travels. Stretched full-bleed via
// preserveAspectRatio="none", so exact geometry matters less than the direction:
@@ -321,132 +300,13 @@ export function WhyNearleDifferent() {
</motion.h2>
<motion.p
variants={fadeUp}
className="mx-auto mt-4 max-w-[600px] text-[17px] leading-[1.7] text-[#5E5866]"
className="mx-auto mt-3 max-w-[560px] text-[15px] sm:text-[17px] leading-[1.6] text-[#5E5866]"
>
Two ways to bring shopping online. One puts the platform in the middle — the other keeps
the shop you already trust there.
One puts big platforms in the middle — the other keeps the shop you already trust there.
</motion.p>
</motion.div>
{/* Sequential reveal: heading (above) → left card → right card, staggered
by the parent; rows stagger within each card. */}
<motion.div
variants={staggerContainer}
initial="hidden"
whileInView="visible"
viewport={viewport}
className="mt-14 grid gap-5 lg:grid-cols-2 lg:gap-6"
>
{/* Traditional commerce */}
<motion.div
variants={slideInLeftSubtle}
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)] transition-shadow duration-300 hover:shadow-[0_18px_44px_rgba(22,0,29,0.09)] 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}
whileHover={reduce ? undefined : { y: -6, scale: 1.02 }}
transition={{ type: "spring", stiffness: 260, damping: 22 }}
className="relative"
>
{/* soft border glow behind the highlighted card */}
<motion.div
aria-hidden="true"
className="pointer-events-none absolute -inset-1 -z-10 rounded-[36px] bg-[#7C3AED]/10 blur-2xl"
animate={reduce ? undefined : { opacity: [0.4, 0.75, 0.4] }}
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
/>
<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">
<motion.span
className="inline-flex"
animate={reduce ? undefined : { scale: [1, 1.12, 1], opacity: [0.85, 1, 0.85] }}
transition={{ duration: 2.6, repeat: Infinity, ease: "easeInOut" }}
>
<MaterialIcon icon="auto_awesome" size={22} />
</motion.span>
</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>
</motion.div>
<CommerceFlipCard />
</div>
</section>
);