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: "2530% 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>
);