"use client"; import { useEffect, useRef, useState, type PointerEvent } from "react"; import { motion, useMotionValue, useReducedMotion } from "motion/react"; type ScratchCardProps = { /** What sits under the foil. */ prize: string; /** Small line under the prize. */ note?: string; title?: string; /** Printed number at the top. */ serial?: string; /** Share of foil to clear before the rest falls away, 0 to 1. */ threshold?: number; /** Printed card colour. */ color?: string; onReveal?: () => void; className?: string; }; // Canvas size. CSS stretches it, so scratches survive a resize. const W = 600; const H = 250; const STROKE = 34; type Fleck = { x: number; y: number; vx: number; vy: number; size: number }; // Same grain every time, so every card comes from the same print run. function roll(n: number) { let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b); h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } function paintFoil(c: CanvasRenderingContext2D) { c.globalCompositeOperation = "source-over"; const steel = c.createLinearGradient(0, 0, W, H); ["#aeb2b8", "#dcdfe3", "#a4a8ae", "#d2d5da", "#b4b8be"].forEach((s, i) => steel.addColorStop(i / 4, s)); c.fillStyle = steel; c.fillRect(0, 0, W, H); for (let i = 0; i < 2600; i++) { c.fillStyle = i % 2 ? "rgba(255,255,255,0.3)" : "rgba(60,64,72,0.2)"; c.fillRect(roll(i * 2) * W, roll(i * 2 + 1) * H, 1.4, 1.4); } c.save(); c.translate(W / 2, H / 2); c.rotate(-0.2); c.font = "800 26px Arial, sans-serif"; c.textAlign = "center"; c.fillStyle = "rgba(70,74,84,0.3)"; for (let row = -3; row <= 3; row++) for (let col = -3; col <= 3; col++) c.fillText("SCRATCH", col * 170 + (row % 2) * 85, row * 58); c.restore(); } /* ScratchCard: a printed card with a silver foil panel. Drag to scratch it off with a coin while flecks fall away. Enter scratches it for you. */ export function ScratchCard({ prize, note, title = "Lucky Seven", serial = "No. 0047291", threshold = 0.55, color = "#0f766e", onReveal, className = "" }: ScratchCardProps) { const reduce = useReducedMotion(); const [round, setRound] = useState(0); const [won, setWon] = useState(false); const [coin, setCoin] = useState(false); const foil = useRef(null); const dust = useRef(null); const panel = useRef(null); const again = useRef(null); const last = useRef<{ x: number; y: number } | null>(null); const flecks = useRef([]); const falling = useRef(0); const strokes = useRef(0); const finished = useRef(false); const coinX = useMotionValue(0); const coinY = useMotionValue(0); useEffect(() => { const c = foil.current?.getContext("2d", { willReadFrequently: true }); if (c) paintFoil(c); finished.current = false; strokes.current = 0; }, [round]); // The panel turns off once it is won, so focus moves to the next control. useEffect(() => { if (won) again.current?.focus({ preventScroll: true }); else if (round) panel.current?.focus({ preventScroll: true }); }, [won, round]); useEffect(() => () => cancelAnimationFrame(falling.current), []); function reveal() { if (finished.current) return; finished.current = true; last.current = null; setCoin(false); setWon(true); onReveal?.(); } function check() { const c = foil.current?.getContext("2d"); if (!c) return; const { data } = c.getImageData(0, 0, W, H); let clear = 0; let all = 0; for (let i = 3; i < data.length; i += 4 * 29) { all += 1; if (data[i] < 40) clear += 1; } if (clear / all >= threshold) reveal(); } function fall() { const c = dust.current?.getContext("2d"); if (!c) return; c.clearRect(0, 0, W, H); flecks.current = flecks.current.filter((f) => f.y < H + 8); c.fillStyle = "#c4c7cc"; for (const f of flecks.current) { f.vy += 0.5; f.x += f.vx; f.y += f.vy; c.fillRect(f.x, f.y, f.size, f.size); } falling.current = flecks.current.length ? requestAnimationFrame(fall) : 0; } function scratch(x: number, y: number, x2: number, y2: number) { const c = foil.current?.getContext("2d"); if (!c || finished.current) return; c.globalCompositeOperation = "destination-out"; c.lineCap = "round"; // One wide stroke plus thin wobbly ones, for a rough edge. for (let i = 0; i < 4; i++) { const wobble = () => (Math.random() - 0.5) * (i ? 22 : 0); c.lineWidth = i ? 12 : STROKE; c.beginPath(); c.moveTo(x + wobble(), y + wobble()); c.lineTo(x2 + wobble(), y2 + wobble()); c.stroke(); } if (!reduce && flecks.current.length < 90) { for (let i = 0; i < 3; i++) flecks.current.push({ x: x2, y: y2, vx: (Math.random() - 0.5) * 5, vy: -Math.random() * 3, size: 1.5 + Math.random() * 3 }); falling.current ||= requestAnimationFrame(fall); } strokes.current += 1; if (strokes.current % 6 === 0) check(); } function at(e: PointerEvent) { const box = e.currentTarget.getBoundingClientRect(); coinX.set(e.clientX - box.left); coinY.set(e.clientY - box.top); return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H }; } function down(e: PointerEvent) { e.currentTarget.setPointerCapture(e.pointerId); const p = at(e); last.current = p; setCoin(true); scratch(p.x, p.y, p.x + 0.1, p.y); } function move(e: PointerEvent) { const p = at(e); if (!last.current) return; scratch(last.current.x, last.current.y, p.x, p.y); last.current = p; } function up() { last.current = null; check(); } // Keyboard and screen reader: the zigzag runs by itself. function autoScratch() { if (reduce) return reveal(); const rows = 6; let t = 0; const step = () => { if (finished.current) return; if (t >= rows) return reveal(); const row = Math.floor(t); const across = row % 2 ? 1 - (t - row) : t - row; const x = across * W; const y = (t / rows) * H; scratch(x, y, x + 1, y + 1); t += 0.07; requestAnimationFrame(step); }; step(); } return (
{title} {serial}
{prize} {note &&

{note}

}

{won ? `You won ${prize}. ${note ?? ""}` : ""}

{won ? "Show this card at the counter" : "Scratch the silver panel with a coin"} {won && ( )}
); }