"use client"; import { ArrowRightIcon, CheckIcon, ChevronsLeftRightIcon, Clock3Icon, CopyMinusIcon, FileSpreadsheetIcon, MailCheckIcon, ReceiptTextIcon, SigmaIcon, } from "lucide-react"; import { type AnimationPlaybackControls, animate, motion, type Transition, useInView, useMotionTemplate, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, } from "motion/react"; import { type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, useEffect, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; function useReducedMotionSafe() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } export type ExpenseCategory = "Meals" | "Travel" | "Software" | "Shipping" | "Office"; export type SheetMark = "highlight" | "error"; export interface ExpenseLine { id: string; date: string; merchant: string; spender?: string; category: ExpenseCategory; amount: number; receipt: "matched" | "found" | "duplicate"; sheet: { date: string; description: string; category?: string; amount: string; note?: string; marks?: Partial>; }; } export interface BeforeAfterHeroProps { eyebrow?: string; headline: { lead: string; before: string; after: string }; description?: ReactNode; cta: { label: string; href?: string }; note?: string; before: { label: string; file: string; editedBy: string; formula: string; status: string; sticky?: string }; after: { product: string; title: string; company: string; status: string }; lines: ExpenseLine[]; currency?: string; defaultPosition?: number; className?: string; } type Side = "before" | "after"; const ease = [0.23, 1, 0.32, 1] as const; const settle: Transition = { type: "spring", stiffness: 420, damping: 36 }; const chase: Transition = { type: "spring", stiffness: 560, damping: 44 }; const tones: Record = { Meals: "bg-amber-500/12 text-amber-700 dark:bg-amber-400/12 dark:text-amber-300", Travel: "bg-sky-500/12 text-sky-700 dark:bg-sky-400/12 dark:text-sky-300", Software: "bg-violet-500/12 text-violet-700 dark:bg-violet-400/14 dark:text-violet-300", Shipping: "bg-rose-500/12 text-rose-700 dark:bg-rose-400/12 dark:text-rose-300", Office: "bg-emerald-500/12 text-emerald-700 dark:bg-emerald-400/12 dark:text-emerald-300", }; const columns = "grid grid-cols-[2rem_minmax(0,1fr)_5.75rem_5.25rem] @2xl/compare:grid-cols-[2.5rem_5rem_minmax(0,1fr)_9rem_7rem_11rem]"; const wideOnly = "hidden @2xl/compare:flex"; const clamp = (value: number) => Math.min(100, Math.max(0, value)); function SheetCell({ children, mark, selected, className, }: { children?: ReactNode; mark?: SheetMark; selected?: boolean; className?: string; }) { return (
{children} {mark === "error" && ( )}
); } function SheetLayer({ lines, before, }: { lines: ExpenseLine[]; before: BeforeAfterHeroProps["before"]; }) { return (
{before.file} {before.editedBy} fx {before.formula} {before.status}
{["A", "B", "C", "D", "E"].map((letter) => ( {letter} ))}
{lines.map((line, i) => (
{i + 1} {line.sheet.date} {line.sheet.description} {line.sheet.category} {line.sheet.amount} {line.sheet.note}
))}
{lines.length + 1} TOTAL #REF! fix before fri!!
{before.sticky && (
{before.sticky}
)}
); } function LedgerLayer({ lines, after, format, }: { lines: ExpenseLine[]; after: BeforeAfterHeroProps["after"]; format: (n: number) => string; }) { const kept = lines.filter((line) => line.receipt !== "duplicate"); const removed = lines.length - kept.length; const total = kept.reduce((sum, line) => sum + line.amount, 0); return (
{after.product.charAt(0)} {after.title} {after.company} {after.status}
Date Merchant Category Amount Receipt
{lines.map((line) => { const duplicate = line.receipt === "duplicate"; return (
{duplicate ? ( ) : ( )} {line.date} {line.merchant.charAt(0)} {line.merchant} {line.spender && ( {line.spender} )} {duplicate ? ( Duplicate ) : ( {line.category} )} {format(line.amount)} {line.receipt === "matched" && } {line.receipt === "found" && } {line.receipt === "matched" ? "Receipt matched" : line.receipt === "found" ? "Found in inbox" : "Merged into row 2"}
); })}
Total {kept.length} expenses{removed > 0 ? `, ${removed} duplicate removed` : ""} {format(total)} Ready to close
); } function Ending({ text, active, side, reduce }: { text: string; active: boolean; side: Side; reduce: boolean }) { const away = side === "before" ? "-0.35em" : "0.35em"; return ( {text} {side === "after" && ( )} ); } export function BeforeAfterHero({ eyebrow, headline, description, cta, note, before, after, lines, currency = "USD", defaultPosition = 55, className, }: BeforeAfterHeroProps) { const reduce = useReducedMotionSafe(); const start = clamp(defaultPosition); const containerRef = useRef(null); const frameRef = useRef(null); const knobRef = useRef(null); const pos = useMotionValue(start); const clamped = useTransform(pos, clamp); const cssPos = useMotionTemplate`${clamped}%`; const [value, setValue] = useState(Math.round(start)); const [side, setSide] = useState(start < 50 ? "after" : "before"); const [vertical, setVertical] = useState(false); const [dragging, setDragging] = useState(false); const [interacted, setInteracted] = useState(false); const [announcement, setAnnouncement] = useState(""); const sideRef = useRef(side); const interactedRef = useRef(false); const targetRef = useRef(start); const motionRef = useRef(null); const hintRef = useRef(null); const dragRef = useRef<{ id: number; offset: number; chasing: boolean } | null>(null); const tapRef = useRef<{ x: number; y: number } | null>(null); const inView = useInView(frameRef, { once: true, amount: 0.6 }); const { format } = new Intl.NumberFormat("en-US", { style: "currency", currency }); useMotionValueEvent(clamped, "change", (v) => { setValue(Math.round(v)); const current = sideRef.current; const next: Side = current === "before" ? (v < 49 ? "after" : "before") : v > 51 ? "before" : "after"; if (next === current) return; sideRef.current = next; setSide(next); if (interactedRef.current) setAnnouncement(`${headline.lead} ${next === "before" ? headline.before : headline.after}`); }); useEffect(() => { const el = containerRef.current; if (!el) return; const check = () => { const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; setVertical(el.clientWidth < 42 * rem); }; check(); const observer = new ResizeObserver(check); observer.observe(el); return () => observer.disconnect(); }, []); useEffect(() => { if (!inView || reduce || interactedRef.current) return; const nudge = start >= 50 ? Math.min(7, 100 - start) : -Math.min(7, start); const timer = window.setTimeout(() => { if (interactedRef.current) return; hintRef.current = animate(pos, [start, start + nudge, start - nudge * 0.18, start], { duration: 1.15, times: [0, 0.42, 0.78, 1], ease: ["easeInOut", "easeOut", "easeInOut"], }); }, 650); return () => { window.clearTimeout(timer); hintRef.current?.stop(); }; }, [inView, reduce, pos, start]); const wake = () => { hintRef.current?.stop(); if (interactedRef.current) return; interactedRef.current = true; setInteracted(true); }; const glide = (target: number, transition: Transition) => { motionRef.current?.stop(); targetRef.current = target; motionRef.current = animate(pos, target, reduce ? { duration: 0 } : transition); return motionRef.current; }; const percentAt = (x: number, y: number) => { const rect = frameRef.current?.getBoundingClientRect(); if (!rect) return pos.get(); return clamp(vertical ? ((y - rect.top) / rect.height) * 100 : ((x - rect.left) / rect.width) * 100); }; const onPointerDown = (e: PointerEvent) => { if (e.button !== 0) return; wake(); const grabbed = (e.target as HTMLElement).closest("[data-grab]") !== null; if (vertical && e.pointerType !== "mouse" && !grabbed) { tapRef.current = { x: e.clientX, y: e.clientY }; return; } if (!knobRef.current?.contains(e.target as Node)) e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); const at = percentAt(e.clientX, e.clientY); if (grabbed) { motionRef.current?.stop(); dragRef.current = { id: e.pointerId, offset: at - pos.get(), chasing: false }; } else { dragRef.current = { id: e.pointerId, offset: 0, chasing: true }; glide(at, chase).finished.then(() => { if (dragRef.current) dragRef.current.chasing = false; }); } setDragging(true); }; const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; if (!drag || drag.id !== e.pointerId) return; const target = clamp(percentAt(e.clientX, e.clientY) - drag.offset); if (drag.chasing && !reduce) { glide(target, chase); return; } motionRef.current?.stop(); targetRef.current = target; pos.set(target); }; const onPointerEnd = (e: PointerEvent) => { const tap = tapRef.current; if (tap) { tapRef.current = null; if (e.type === "pointerup" && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) < 10) { glide(percentAt(e.clientX, e.clientY), settle); } return; } const drag = dragRef.current; if (!drag || drag.id !== e.pointerId) return; dragRef.current = null; setDragging(false); const velocity = pos.getVelocity(); let target = drag.chasing ? targetRef.current : clamp(pos.get() + velocity * 0.12); if (target < 8) target = 0; else if (target > 92) target = 100; glide(target, { type: "spring", stiffness: 300, damping: 20, velocity }); }; const onKeyDown = (e: KeyboardEvent) => { const step = e.shiftKey ? 10 : 5; const page = vertical ? -20 : 20; const moves: Record = vertical ? { ArrowUp: -step, ArrowLeft: -step, ArrowDown: step, ArrowRight: step, PageUp: page, PageDown: -page } : { ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step, PageUp: page, PageDown: -page }; let next: number; if (e.key === "Home") next = 0; else if (e.key === "End") next = 100; else if (e.key in moves) next = Math.round(targetRef.current) + moves[e.key]; else return; e.preventDefault(); wake(); glide(clamp(next), settle); }; const beforeLabel = before.label; const afterLabel = after.product; return (
{eyebrow &&

{eyebrow}

}

{headline.lead}

{announcement}

{description && (

{description}

)}
{note && {note}}
Drag to compare
); }