Add 500K celebration changes
19
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/edge-config": "^1.4.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"js-sha1": "^0.7.0",
|
||||
@@ -45,6 +46,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/mjml": "^5.0.0",
|
||||
"@types/mjml-core": "^5.0.0",
|
||||
"@types/node": "^20",
|
||||
@@ -2779,6 +2781,13 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/canvas-confetti": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
|
||||
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -3628,6 +3637,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||
"license": "ISC",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/edge-config": "^1.4.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"js-sha1": "^0.7.0",
|
||||
@@ -46,6 +47,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/mjml": "^5.0.0",
|
||||
"@types/mjml-core": "^5.0.0",
|
||||
"@types/node": "^20",
|
||||
|
||||
BIN
public/homepage/celias-stupid-romhack.gif
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
public/homepage/emerald-rogue.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/homepage/pokemon-crystal-advance-redux.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/homepage/pokemon-odyssey.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/homepage/pokemon-rowe.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/homepage/pokemon-unbound.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/homepage/super-mariomon.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/homepage/sword-and-shield-ultimate-plus.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/homepage/too-many-types-2.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -102,6 +102,23 @@ html { scroll-padding-top: var(--anchor-offset); }
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Interpolatable ready-ring for homepage hero hack cards (Tailwind ring-* does not transition cleanly) */
|
||||
.hero-hack-card {
|
||||
box-shadow: 0 0 0 1px var(--border);
|
||||
transition: box-shadow 500ms ease;
|
||||
}
|
||||
.hero-hack-card[data-ready="true"] {
|
||||
box-shadow: 0 0 0 1px rgb(52 211 153 / 0.5); /* emerald-400/50 */
|
||||
}
|
||||
|
||||
@keyframes heroHackFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.hero-hack-image {
|
||||
animation: heroHackFadeIn 500ms ease forwards;
|
||||
}
|
||||
.card-simple {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
@@ -157,6 +174,25 @@ html { scroll-padding-top: var(--anchor-offset); }
|
||||
.anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; }
|
||||
.anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
|
||||
@keyframes heroConnectorDash {
|
||||
to { stroke-dashoffset: -24; }
|
||||
}
|
||||
|
||||
.hero-connector-path {
|
||||
stroke-dasharray: 6 6;
|
||||
animation: heroConnectorDash 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-float,
|
||||
.anim-pulse,
|
||||
.anim-pop,
|
||||
.hero-connector-path,
|
||||
.hero-hack-image {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.shine-wrap { position: relative; overflow: hidden; border-radius: inherit; }
|
||||
.shine-wrap::after {
|
||||
content: "";
|
||||
|
||||
103
src/app/page.tsx
@@ -4,6 +4,9 @@ import { FaArrowRightLong } from "react-icons/fa6";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import HackCard from "@/components/HackCard";
|
||||
import Button from "@/components/Button";
|
||||
import MilestoneCelebration from "@/components/Home/MilestoneCelebration";
|
||||
import HeroPatchDiagram from "@/components/Home/HeroPatchDiagram";
|
||||
import HeroAuthCta from "@/components/Home/HeroAuthCta";
|
||||
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
|
||||
import { HackCardAttributes } from "@/components/HackCard";
|
||||
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
|
||||
@@ -146,51 +149,77 @@ export default async function Home() {
|
||||
<div>
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-grid opacity-30 pointer-events-none" />
|
||||
<div className="mx-auto max-w-screen-2xl px-6 py-10 sm:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
|
||||
<span className="gradient-text">Discover</span> and share Pokémon romhacks
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] text-foreground/80">
|
||||
Find community-made hacks and patch in-browser with your own legally-obtained base ROMs for Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.
|
||||
</p>
|
||||
<div className="mt-12 sm:mt-8 mx-auto flex flex-col items-start max-w-[320px] sm:flex-row sm:items-center sm:max-w-none gap-3">
|
||||
<Link
|
||||
href="/discover"
|
||||
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-semibold sm:font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
|
||||
>
|
||||
Explore hacks
|
||||
</Link>
|
||||
<Link
|
||||
href="/submit"
|
||||
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md border border-white/10 bg-white/10 px-5 text-base font-semibold sm:font-medium text-foreground transition-colors hover:bg-white/15 elevate"
|
||||
>
|
||||
Submit a patch
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md sm:px-5 text-base font-medium text-foreground/90 hover:underline"
|
||||
>
|
||||
Already a creator? Log in
|
||||
</Link>
|
||||
<div className="mx-auto max-w-screen-2xl px-6 py-12 sm:py-20">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,34rem)] xl:grid-cols-[minmax(0,1fr)_minmax(0,42rem)]">
|
||||
<div>
|
||||
<MilestoneCelebration />
|
||||
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl text-balance">
|
||||
Bring your ROM once.
|
||||
<br />
|
||||
<span className="gradient-text">Play everything it unlocks.</span>
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-foreground/80">
|
||||
Link your legally-obtained base ROM one time and Hackdex caches it on your
|
||||
device. Patch any Pokémon ROM hack built for it, right in your browser,
|
||||
without ever re-uploading. Your files never leave your device.
|
||||
</p>
|
||||
<div className="mt-8 mx-auto flex w-full max-w-[320px] flex-col items-start gap-3 sm:mx-0 sm:max-w-none sm:flex-row sm:items-center">
|
||||
<Link
|
||||
href="/discover"
|
||||
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-semibold sm:font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
|
||||
>
|
||||
Explore hacks
|
||||
</Link>
|
||||
<Link
|
||||
href="/submit"
|
||||
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md border border-white/10 bg-white/10 px-5 text-base font-semibold sm:font-medium text-foreground transition-colors hover:bg-white/15 elevate"
|
||||
>
|
||||
Submit a patch
|
||||
</Link>
|
||||
<HeroAuthCta />
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-foreground/60">
|
||||
Supports Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.
|
||||
</p>
|
||||
</div>
|
||||
<HeroPatchDiagram />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`mx-auto max-w-screen-2xl px-6 ${hackData.length > 0 ? "pt-6 sm:pt-12" : "py-6 sm:py-12"}`}>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
<div className="card p-5">
|
||||
<div className="text-[15px] font-semibold tracking-tight">Curated discovery</div>
|
||||
<p className="mt-1 text-sm text-foreground/70">Browse popular and trending patches across generations.</p>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-foreground/60">How it works</h2>
|
||||
<div className="relative mt-4 grid gap-8 sm:grid-cols-3 sm:gap-6">
|
||||
<div
|
||||
className="pointer-events-none absolute top-3.5 right-[calc(16.666%-0.875rem)] left-3.5 hidden h-px bg-[var(--border)] sm:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
|
||||
1
|
||||
</span>
|
||||
<div className="mt-3 text-[15px] font-semibold tracking-tight">Link your base ROM</div>
|
||||
<p className="mt-1 max-w-xs text-sm text-foreground/70">
|
||||
Point Hackdex at your legally-obtained ROM file. It's verified and matched automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-5">
|
||||
<div className="text-[15px] font-semibold tracking-tight">Built-in patcher</div>
|
||||
<p className="mt-1 text-sm text-foreground/70">Provide your base ROMs and patch in the browser.</p>
|
||||
<div>
|
||||
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
|
||||
2
|
||||
</span>
|
||||
<div className="mt-3 text-[15px] font-semibold tracking-tight">It stays on your device</div>
|
||||
<p className="mt-1 max-w-xs text-sm text-foreground/70">
|
||||
Your ROM is cached locally in your browser, never uploaded, and ready whenever you come back.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-5">
|
||||
<div className="text-[15px] font-semibold tracking-tight">Submit your own</div>
|
||||
<p className="mt-1 text-sm text-foreground/70">Join as a creator and submit your own rom hacks.</p>
|
||||
<div>
|
||||
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
|
||||
3
|
||||
</span>
|
||||
<div className="mt-3 text-[15px] font-semibold tracking-tight">Patch hack after hack</div>
|
||||
<p className="mt-1 max-w-xs text-sm text-foreground/70">
|
||||
Pick any hack built for your ROM and patch it in your browser in seconds, with no re-uploading between hacks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-6 mx-auto flex flex-col items-center max-w-[320px] sm:mt-10">
|
||||
|
||||
25
src/components/Home/HeroAuthCta.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
const linkClassName =
|
||||
"inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md sm:px-5 text-base font-medium text-foreground/90 hover:underline";
|
||||
|
||||
export default function HeroAuthCta() {
|
||||
const { user } = useAuthContext();
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<Link href="/dashboard" className={linkClassName}>
|
||||
Go to dashboard
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="/login" className={linkClassName}>
|
||||
Already a creator? Log in
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
118
src/components/Home/HeroPatchDiagram.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { IoGameControllerOutline } from "react-icons/io5";
|
||||
|
||||
const HOMEPAGE_HACKS = [
|
||||
{ title: "Too Many Types 2", src: "/homepage/too-many-types-2.png" },
|
||||
{ title: "Pokémon Odyssey", src: "/homepage/pokemon-odyssey.png" },
|
||||
{ title: "Super Mariomon", src: "/homepage/super-mariomon.png" },
|
||||
{ title: "Sword and Shield Ultimate Plus", src: "/homepage/sword-and-shield-ultimate-plus.png" },
|
||||
{ title: "Pokémon Crystal Advance Redux", src: "/homepage/pokemon-crystal-advance-redux.png" },
|
||||
{ title: "Pokémon ROWE", src: "/homepage/pokemon-rowe.png" },
|
||||
{ title: "Emerald Rogue", src: "/homepage/emerald-rogue.png" },
|
||||
{ title: "Celia's Stupid Romhack", src: "/homepage/celias-stupid-romhack.gif" },
|
||||
{ title: "Pokémon Unbound", src: "/homepage/pokemon-unbound.png" },
|
||||
] as const;
|
||||
|
||||
type Pick = (typeof HOMEPAGE_HACKS)[number] & { delay: string };
|
||||
|
||||
export default function HeroPatchDiagram() {
|
||||
const [picks, setPicks] = useState<Pick[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setPicks(
|
||||
[...HOMEPAGE_HACKS]
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, 3)
|
||||
.map((h, i) => ({ ...h, delay: `${i * 0.6}s` }))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const slots: (Pick | null)[] = picks.length === 3 ? picks : [null, null, null];
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-full" aria-hidden="true">
|
||||
<div className="flex w-full flex-col items-center gap-0">
|
||||
{/* Base ROM card */}
|
||||
<div className="card relative z-10 w-full max-w-64 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--surface-2)] ring-1 ring-[var(--border)]">
|
||||
<IoGameControllerOutline size={18} className="text-foreground/70" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold tracking-tight">Your base ROM</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400" />
|
||||
<span className="truncate text-xs text-emerald-400">Cached on this device</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connectors + cards share one shrink-wrapped width so they stay centered together */}
|
||||
<div className="flex w-full max-w-md flex-col items-center md:w-fit md:max-w-none">
|
||||
<div className="relative h-14 w-full sm:h-[4.75rem]">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full overflow-visible"
|
||||
viewBox="0 0 416 76"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
className="hero-connector-path"
|
||||
d="M208 0 C208 28, 68 28, 68 56"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="hero-connector-path"
|
||||
d="M208 0 C208 36, 208 36, 208 76"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="hero-connector-path"
|
||||
d="M208 0 C208 28, 348 28, 348 56"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex w-full items-start gap-2 sm:gap-3 xl:gap-4">
|
||||
{slots.map((hack, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-ready={hack ? "true" : "false"}
|
||||
className={`hero-hack-card card anim-float min-w-0 flex-1 p-2 sm:p-2.5 md:w-32 md:flex-none lg:w-36 xl:w-40 ${index === 1 ? "mt-3 sm:mt-5" : ""}`}
|
||||
style={{ animationDelay: hack?.delay ?? `${index * 0.6}s` }}
|
||||
>
|
||||
<div className="relative aspect-[3/2] w-full overflow-hidden rounded-md bg-[var(--surface-2)]">
|
||||
{hack && (
|
||||
<Image
|
||||
src={hack.src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="160px"
|
||||
className="hero-hack-image object-cover"
|
||||
unoptimized={hack.src.endsWith(".gif")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div data-ready={hack ? "true" : "false"} className="mt-1.5 truncate text-[11px] font-medium tracking-tight text-foreground/85 sm:mt-2 sm:text-xs opacity-0 data-[ready=true]:opacity-100 transition-opacity duration-500">
|
||||
{hack?.title ?? "\u00A0" /* non-breaking space */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/Home/MilestoneCelebration.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { PiConfettiBold } from "react-icons/pi";
|
||||
|
||||
const STORAGE_KEY = "hackdex:500k-confetti";
|
||||
const COLORS = ["#f43f5e", "#f97316", "#f59e0b", "#fb7185"];
|
||||
const BURST_MS_DESKTOP = 2200;
|
||||
const BURST_MS_MOBILE = 1400;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
return window.matchMedia("(max-width: 640px)").matches;
|
||||
}
|
||||
|
||||
export default function MilestoneCelebration() {
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
const stopConfetti = useCallback(() => {
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fireConfetti = useCallback(async () => {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
stopConfetti();
|
||||
const { default: confetti } = await import("canvas-confetti");
|
||||
const mobile = isMobileViewport();
|
||||
const end = Date.now() + (mobile ? BURST_MS_MOBILE : BURST_MS_DESKTOP);
|
||||
const particleCount = mobile ? 2 : 3;
|
||||
const startVelocity = mobile ? 64 : 55;
|
||||
const originY = mobile ? 0.95 : 0.65;
|
||||
|
||||
const frame = () => {
|
||||
if (Date.now() > end) {
|
||||
frameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
confetti({
|
||||
particleCount,
|
||||
angle: mobile ? 75 : 60,
|
||||
spread: mobile ? 40 : 50,
|
||||
startVelocity,
|
||||
origin: { x: 0, y: originY },
|
||||
colors: COLORS,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
confetti({
|
||||
particleCount,
|
||||
angle: mobile ? 105 : 120,
|
||||
spread: mobile ? 40 : 50,
|
||||
startVelocity,
|
||||
origin: { x: 1, y: originY },
|
||||
colors: COLORS,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
|
||||
frameRef.current = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
frameRef.current = requestAnimationFrame(frame);
|
||||
}, [stopConfetti]);
|
||||
|
||||
useEffect(() => {
|
||||
let alreadyShown = false;
|
||||
try {
|
||||
alreadyShown = localStorage.getItem(STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
// Private browsing or storage blocked
|
||||
}
|
||||
|
||||
if (alreadyShown || prefersReducedMotion()) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void fireConfetti();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, "1");
|
||||
} catch {
|
||||
// Private browsing or storage blocked
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
stopConfetti();
|
||||
};
|
||||
}, [fireConfetti, stopConfetti]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fireConfetti()}
|
||||
className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm ring-1 ring-[var(--accent)]/30 bg-[var(--accent)]/10 text-foreground/90 elevate hover:ring-[var(--accent)]/50 transition cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<PiConfettiBold size={16} className="text-[var(--accent)]" aria-hidden="true" />
|
||||
<span className="font-semibold gradient-text">500,000+ downloads</span>
|
||||
<span className="-ml-0.5">Thank you!</span>
|
||||
<span className="sr-only">. Activate to replay the celebration confetti.</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||