"use client"; import { EllipsisVertical, Eye, EyeOff } from "lucide-react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { DigitSwap } from "@/components/motion/digit-swap"; import { EASE_IN_OUT, EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, } from "@/lib/ease"; import { cn } from "@/lib/utils"; // The two purse layers collapse as one material surface after the card starts // moving, then reverse immediately so closing feels like the card is caught. const PURSE_MORPH_TRANSITION = { duration: 0.28, ease: EASE_IN_OUT, } as const; const PURSE_REDUCED_TRANSITION = { duration: 0.16, ease: EASE_OUT, } as const; export interface CardFolderProps { title: string; cardNumber: string; expiry: string; cvv: string; card: ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; detailsVisible?: boolean; defaultDetailsVisible?: boolean; onDetailsVisibleChange?: (visible: boolean) => void; onClick?: () => void; onAction?: () => void; ariaLabel?: string; actionLabel?: string; disabled?: boolean; className?: string; cardClassName?: string; } /** * A landscape card tucked into an animated folder sleeve. Pressing the folder * lifts the card forward while the purse compresses into its bottom seam; a * separate privacy control reveals its number and CVV. */ export function CardFolder({ title, cardNumber, expiry, cvv, card, open, defaultOpen = false, onOpenChange, detailsVisible, defaultDetailsVisible = false, onDetailsVisibleChange, onClick, onAction, ariaLabel, actionLabel, disabled = false, className, cardClassName, }: CardFolderProps) { const reduce = useReducedMotion(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const [internalDetailsVisible, setInternalDetailsVisible] = useState( defaultDetailsVisible, ); const openControlled = open !== undefined; const detailsControlled = detailsVisible !== undefined; const isOpen = open ?? internalOpen; const areDetailsVisible = detailsVisible ?? internalDetailsVisible; const transition = reduce ? { duration: 0 } : SPRING_LAYOUT; const normalizedCardNumber = cardNumber.replace(/\D/g, ""); const visibleLastFour = normalizedCardNumber.slice(-4).padStart(4, "•"); const revealedCardNumber = normalizedCardNumber.match(/.{1,4}/g)?.join(" ") ?? visibleLastFour; const maskedCvv = "•".repeat(Math.max(3, cvv.length)); const defaultAriaLabel = `${isOpen ? "Close" : "Open"} ${title}, card ending in ${visibleLastFour}, expires ${expiry}`; const progress = useMotionValue(isOpen ? 1 : 0); const cardTransform = useTransform(progress, (value) => { const boundedProgress = Math.min(1, Math.max(0, value)); const lift = Math.sin(Math.PI * boundedProgress); return `translateY(${-8 * lift}%) scale(${1 + 0.01 * lift})`; }); const backTransform = useTransform( progress, [0, 1], ["translateY(0%) scaleY(1)", "translateY(18%) scaleY(0.18)"], ); const frontTransform = useTransform( progress, [0, 1], ["translateY(0%) rotateX(0deg)", "translateY(18%) rotateX(-72deg)"], ); const purseOpacity = useTransform(progress, [0, 0.76, 1], [1, 1, 0]); useEffect(() => { const controls = animate( progress, isOpen ? 1 : 0, reduce ? { duration: 0 } : PURSE_MORPH_TRANSITION, ); return () => controls.stop(); }, [isOpen, progress, reduce]); const setOpen = useCallback( (nextOpen: boolean) => { if (disabled) return; if (!openControlled) setInternalOpen(nextOpen); onOpenChange?.(nextOpen); }, [disabled, onOpenChange, openControlled], ); const handleClick = () => { setOpen(!isOpen); onClick?.(); }; const toggleDetails = () => { if (disabled) return; const nextVisible = !areDetailsVisible; if (!detailsControlled) setInternalDetailsVisible(nextVisible); onDetailsVisibleChange?.(nextVisible); }; return (
{areDetailsVisible ? ( Expiry {expiry} CVV {title} {onAction ? ( ) : null}
); }