"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDownLeft, BatteryFull, Check, CreditCard, PiggyBank, Plane, ShoppingBag, Signal, Smartphone, TabletSmartphone, TrendingUp, Wifi } from "lucide-react";
import { cn } from "@/lib/utils";
export type AppNotification = {
title: string;
body: string;
time: string;
icon: "income" | "card" | "savings" | "shopping" | "travel" | "invest";
};
export interface CtaAppDownloadProps {
eyebrow?: string;
title?: React.ReactNode;
description?: string;
bullets?: string[];
appName?: string;
primary?: { label: string; caption: string; href: string };
secondary?: { label: string; caption: string; href: string };
qrCaption?: string;
rating?: string;
notifications?: AppNotification[];
/** Milliseconds between new notifications on the phone screen. */
notificationInterval?: number;
className?: string;
}
const NOTIF_ICONS = {
income: { Icon: ArrowDownLeft, cls: "bg-emerald-500" },
card: { Icon: CreditCard, cls: "bg-violet-500" },
savings: { Icon: PiggyBank, cls: "bg-pink-500" },
shopping: { Icon: ShoppingBag, cls: "bg-amber-500" },
travel: { Icon: Plane, cls: "bg-sky-500" },
invest: { Icon: TrendingUp, cls: "bg-indigo-500" },
} as const;
const DEFAULT_NOTIFICATIONS: AppNotification[] = [
{ icon: "income", title: "Salary received", body: "+$4,250.00 from Northwind Inc.", time: "3m" },
{ icon: "savings", title: "Round-ups saved", body: "You saved $18.40 this week", time: "2m" },
{ icon: "card", title: "Card payment", body: "$12.80 at Lumen Coffee", time: "5m" },
{ icon: "invest", title: "Portfolio up 2.4%", body: "Your Orbit index had a great day", time: "12m" },
{ icon: "travel", title: "Travel mode on", body: "No fees in 40+ countries", time: "1h" },
{ icon: "shopping", title: "Budget on track", body: "$320 left for shopping in May", time: "2h" },
];
/* ---------- deterministic QR-like pattern (decorative) ---------- */
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const QR_SIZE = 25;
const QR_CELLS: [number, number][] = (() => {
const rand = mulberry32(20260924);
const cells: [number, number][] = [];
const inFinder = (x: number, y: number) =>
(x < 8 && y < 8) || (x >= QR_SIZE - 8 && y < 8) || (x < 8 && y >= QR_SIZE - 8);
const inCenter = (x: number, y: number) => Math.abs(x - 12) <= 2 && Math.abs(y - 12) <= 2;
for (let y = 0; y < QR_SIZE; y++)
for (let x = 0; x < QR_SIZE; x++) if (!inFinder(x, y) && !inCenter(x, y) && rand() > 0.52) cells.push([x, y]);
return cells;
})();
function QrPattern({ className }: { className?: string }) {
const finder = (x: number, y: number) => (
<g key={`${x}-${y}`}>
<rect x={x + 0.5} y={y + 0.5} width={6} height={6} rx={1.6} fill="none" stroke="currentColor" strokeWidth={1} />
<rect x={x + 2} y={y + 2} width={3} height={3} rx={0.8} fill="currentColor" />
</g>
);
return (
<svg viewBox={`0 0 ${QR_SIZE} ${QR_SIZE}`} className={className} aria-hidden shapeRendering="geometricPrecision">
{QR_CELLS.map(([x, y]) => (
<rect key={`${x}-${y}`} x={x + 0.1} y={y + 0.1} width={0.8} height={0.8} rx={0.25} fill="currentColor" />
))}
{finder(0, 0)}
{finder(QR_SIZE - 7, 0)}
{finder(0, QR_SIZE - 7)}
<rect x={10.2} y={10.2} width={4.6} height={4.6} rx={1.2} className="fill-primary" />
<path d="M11.6 12.6l0.9 0.9 1.7-1.9" fill="none" stroke="white" strokeWidth={0.55} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* ---------- phone ---------- */
function Phone({ appName, notifications, interval }: { appName: string; notifications: AppNotification[]; interval: number }) {
const reduce = useReducedMotion();
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
if (reduce || notifications.length === 0) return;
const id = setInterval(() => setTick((t) => t + 1), interval);
return () => clearInterval(id);
}, [interval, notifications.length, reduce]);
const visible = reduce ? 3 : Math.min(3, tick + 1);
const stack = Array.from({ length: visible }, (_, i) => {
const n = tick - i;
return { key: n, n: notifications[((n % notifications.length) + notifications.length) % notifications.length] };
});
return (
<motion.div
className="relative"
animate={reduce ? undefined : { y: [0, -10, 0] }}
transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
>
{/* side buttons */}
<span aria-hidden className="absolute -left-[3px] top-28 h-8 w-[3px] rounded-l bg-neutral-700" />
<span aria-hidden className="absolute -left-[3px] top-40 h-14 w-[3px] rounded-l bg-neutral-700" />
<span aria-hidden className="absolute -right-[3px] top-36 h-20 w-[3px] rounded-r bg-neutral-700" />
<div className="relative h-[520px] w-[256px] rounded-[3rem] bg-neutral-900 p-[10px] shadow-[0_40px_80px_-20px_rgb(0_0_0/0.45)] ring-1 ring-neutral-700 sm:h-[580px] sm:w-[284px] dark:ring-neutral-600">
<div className="relative size-full overflow-hidden rounded-[2.4rem] bg-background">
{/* wallpaper */}
<div aria-hidden className="absolute inset-0 bg-[radial-gradient(120%_60%_at_100%_0%,color-mix(in_oklch,var(--primary)_28%,transparent),transparent),radial-gradient(90%_50%_at_0%_100%,rgb(236_72_153/0.18),transparent)]" />
{/* status bar */}
<div className="relative flex items-center justify-between px-6 pt-3.5 sm:px-7 text-[11px] font-semibold text-foreground">
<span>9:41</span>
<span className="flex items-center gap-1">
<Signal className="size-3" />
<Wifi className="size-3" />
<BatteryFull className="size-4" />
</span>
</div>
<div aria-hidden className="absolute left-1/2 top-2.5 h-6 w-[72px] -translate-x-1/2 sm:w-[88px] rounded-full bg-neutral-900" />
{/* app content */}
<div className="relative px-4 pt-5">
<div className="flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground">Good morning</p>
<p className="text-sm font-semibold text-foreground">Sam Carter</p>
</div>
<span className="grid size-8 place-items-center rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 text-[11px] font-semibold text-white">SC</span>
</div>
<div className="mt-4 rounded-2xl bg-gradient-to-br from-violet-600 via-fuchsia-600 to-rose-500 p-4 text-white shadow-lg shadow-fuchsia-500/20">
<p className="text-[11px] text-white/80">Total balance</p>
<p className="mt-1 text-2xl font-semibold tracking-tight">$12,480.20</p>
<div className="mt-3 flex items-end gap-1" aria-hidden>
{[40, 62, 48, 70, 55, 82, 66, 90, 74, 96].map((h, i) => (
<motion.span
key={i}
className="w-full rounded-sm bg-white/35"
initial={{ height: 4 }}
animate={{ height: h * 0.32 }}
transition={{ delay: 0.3 + i * 0.05, type: "spring", stiffness: 200, damping: 18 }}
/>
))}
</div>
</div>
<p className="mt-5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Activity</p>
<div className="relative mt-2 h-[228px] sm:h-[270px]">
<AnimatePresence initial={false} mode="popLayout">
{stack.map(({ key, n }, i) => {
const { Icon, cls } = NOTIF_ICONS[n.icon];
return (
<motion.div
key={key}
layout
initial={{ opacity: 0, y: -40, scale: 0.9 }}
animate={{ opacity: 1 - i * 0.18, y: 0, scale: 1 - i * 0.02 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="mb-2 flex items-center gap-3 rounded-2xl border bg-card/90 p-3 shadow-sm backdrop-blur"
>
<span className={cn("grid size-8 shrink-0 place-items-center rounded-xl text-white", cls)}>
<Icon aria-hidden className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate text-[12px] font-semibold text-foreground">{n.title}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{i === 0 ? "now" : n.time}</span>
</span>
<span className="block truncate text-[11px] text-muted-foreground">{n.body}</span>
</span>
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
{/* tab bar */}
<div className="absolute inset-x-3 bottom-3 flex items-center justify-around rounded-2xl border bg-card/90 py-2.5 backdrop-blur">
{[0, 1, 2, 3].map((i) => (
<span key={i} aria-hidden className={cn("h-1.5 rounded-full", i === 0 ? "w-6 bg-primary" : "w-1.5 bg-muted-foreground/40")} />
))}
</div>
<span aria-hidden className="absolute bottom-1 left-1/2 h-1 w-24 -translate-x-1/2 rounded-full bg-foreground/30" />
</div>
</div>
<p className="sr-only">{appName} app preview showing live account notifications.</p>
</motion.div>
);
}
function StoreButton({ label, caption, href, icon, variant }: { label: string; caption: string; href: string; icon: React.ReactNode; variant: "solid" | "outline" }) {
return (
<a
href={href}
className={cn(
"group inline-flex h-14 items-center gap-2.5 rounded-2xl px-3.5 transition sm:gap-3 sm:px-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
variant === "solid" ? "bg-foreground text-background shadow-lg hover:-translate-y-0.5 hover:shadow-xl" : "border bg-card text-foreground hover:-translate-y-0.5 hover:bg-accent",
)}
>
<span className="transition-transform group-hover:scale-110">{icon}</span>
<span className="text-left leading-tight">
<span className="block text-[10px] font-medium uppercase tracking-wider opacity-70">{caption}</span>
<span className="block text-base font-semibold">{label}</span>
</span>
</a>
);
}
export function CtaAppDownload({
eyebrow = "Mobile app",
title = (
<>
Your money,{" "}
<span className="bg-gradient-to-r from-violet-600 via-fuchsia-500 to-rose-500 bg-clip-text text-transparent dark:from-violet-400 dark:via-fuchsia-400 dark:to-rose-400">
always in reach
</span>
</>
),
description = "Get instant alerts, freeze your card in a tap and watch your savings grow — all from one beautifully simple app.",
bullets = ["Real-time notifications", "Face unlock & card freeze", "Free transfers in 40+ countries"],
appName = "Lumen",
primary = { label: "iOS", caption: "Download for", href: "#ios" },
secondary = { label: "Android", caption: "Get it for", href: "#android" },
qrCaption = "Scan with your phone camera to download",
rating = "4.9 · 38k ratings",
notifications = DEFAULT_NOTIFICATIONS,
notificationInterval = 2400,
className,
}: CtaAppDownloadProps) {
const reduce = useReducedMotion();
return (
<section className={cn("relative w-full bg-background px-4 py-10 sm:px-6 sm:py-12", className)}>
<div className="relative mx-auto max-w-6xl overflow-hidden rounded-[2.5rem] border bg-card">
<div aria-hidden className="pointer-events-none absolute inset-0 [background-image:linear-gradient(to_right,color-mix(in_oklch,var(--foreground)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklch,var(--foreground)_6%,transparent)_1px,transparent_1px)] [background-size:40px_40px] [mask-image:radial-gradient(70%_80%_at_75%_50%,#000,transparent)]" />
<div aria-hidden className="pointer-events-none absolute -right-20 top-1/2 size-[520px] -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--primary)_30%,transparent),transparent)]" />
<div className="relative grid items-center gap-12 px-6 py-12 sm:px-12 sm:py-14 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-10 lg:py-10 lg:pl-16 lg:pr-24">
<motion.div
initial={reduce ? false : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<p className="inline-flex items-center gap-2 rounded-full border bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
<Smartphone aria-hidden className="size-3.5 text-primary" />
{eyebrow}
</p>
<h2 className="mt-5 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl lg:text-6xl">{title}</h2>
<p className="mt-5 max-w-lg text-pretty text-base leading-relaxed text-muted-foreground sm:text-lg">{description}</p>
<ul className="mt-6 space-y-2.5">
{bullets.map((b) => (
<li key={b} className="flex items-center gap-3 text-sm text-foreground/85 sm:text-base">
<span className="grid size-5 place-items-center rounded-full bg-primary/12 text-primary">
<Check aria-hidden className="size-3" strokeWidth={3} />
</span>
{b}
</li>
))}
</ul>
<div className="mt-9 grid grid-cols-2 gap-2.5 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<StoreButton {...primary} variant="solid" icon={<Smartphone aria-hidden className="size-6" />} />
<StoreButton {...secondary} variant="outline" icon={<TabletSmartphone aria-hidden className="size-6" />} />
</div>
<div className="mt-8 hidden items-center gap-4 sm:flex">
<div className="rounded-2xl border bg-background p-2.5 shadow-sm">
<QrPattern className="size-20 text-foreground" />
</div>
<div>
<p className="max-w-[12rem] text-sm font-medium text-foreground">{qrCaption}</p>
<p className="mt-1 text-xs text-muted-foreground">
<span className="text-amber-500">★★★★★</span> {rating}
</p>
</div>
</div>
</motion.div>
<div className="flex justify-center lg:justify-end">
<Phone appName={appName} notifications={notifications} interval={notificationInterval} />
</div>
</div>
</div>
</section>
);
}