diff --git a/src/components/home/CommerceCompareSlider.tsx b/src/components/home/CommerceCompareSlider.tsx new file mode 100644 index 0000000..7c08527 --- /dev/null +++ b/src/components/home/CommerceCompareSlider.tsx @@ -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 ( +
+
+ + + +
+

Today

+

+ Traditional Commerce +

+
+
+ + +
+ ); +} + +function NearlePanel({ ghost }: PanelProps) { + return ( +
+
+ + + +
+

Nearle

+

+ Nearle AI Commerce +

+
+
+ + +
+ ); +} + +export function CommerceCompareSlider() { + const reduce = useReducedMotion(); + const wrapRef = useRef(null); + const cardRef = useRef(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(null); + const hintTimer = useRef | 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 ( + +
+ {/* Invisible in-flow copy: gives the card a real height so neither + absolutely-positioned layer can ever be clipped on small screens. */} + + + {/* Bottom layer — the world as it works today. */} + + + {/* Top layer — clipped from the left by the divider. */} +
+ +
+ + {/* Seam: an opaque strip under the divider so the two halves never read + as one run-on sentence when the rows meet mid-word. */} + );