Skip to content

Commit ed0ce33

Browse files
NiallJoeMaherclaude
andcommitted
feat(relaunch): onboarding celebration — confetti + badge unlock (handoff #5 §4)
New components/Celebrate/: Confetti (canvas burst, mint/status palette, ~1.7s, early-returns under prefers-reduced-motion) and BadgeUnlock (full-screen dialog, hexagon badge tile with codu-badge-pop + ring-pulse, "// badge unlocked", +points, Keep browsing / See your badges → profile achievements). Fires once when the real onboarding wins all complete — guarded by a localStorage flag (read via useSyncExternalStore) + a close flag, trigger in an effect (no setState-in-effect). username threaded through ShellActions for the badges link. Keyframes added to globals (motion gated on prefers-reduced-motion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 149ab67 commit ed0ce33

5 files changed

Lines changed: 298 additions & 5 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
5+
// Regular hexagon (flat-top) clip-path — matches the kit's badge tile shape.
6+
const HEX_CLIP =
7+
"polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
8+
9+
interface BadgeUnlockProps {
10+
/** Display name of the unlocked badge, e.g. "First Post". */
11+
badgeName: string;
12+
/** Points awarded, shown as `+{points}`. */
13+
points: number;
14+
/** Current user's username, for the "See your badges" profile link. */
15+
username: string | null;
16+
/** Close the dialog. */
17+
onClose: () => void;
18+
}
19+
20+
/**
21+
* Full-screen celebration dialog shown when a user unlocks their first badge.
22+
* A hexagon badge tile pops in (with a pulsing halo) above an eyebrow, the badge
23+
* name, the points line, and two actions. Motion is CSS-gated on
24+
* prefers-reduced-motion — reduced-motion users get the static dialog.
25+
*/
26+
export function BadgeUnlock({
27+
badgeName,
28+
points,
29+
username,
30+
onClose,
31+
}: BadgeUnlockProps) {
32+
const badgesHref = username ? `/${username}?tab=achievements` : "/settings";
33+
34+
return (
35+
<div
36+
onClick={onClose}
37+
className="fixed inset-0 z-[95] flex items-center justify-center overflow-y-auto px-6 py-10"
38+
style={{ background: "rgba(4,5,7,0.72)", backdropFilter: "blur(6px)" }}
39+
>
40+
<div
41+
onClick={(e) => e.stopPropagation()}
42+
role="dialog"
43+
aria-modal="true"
44+
aria-label="Badge unlocked"
45+
className="w-full max-w-[420px] overflow-hidden rounded-xl border border-strong bg-elevated p-8 text-center shadow-pop"
46+
>
47+
{/* Badge tile + halo */}
48+
<div className="relative mx-auto flex h-28 w-28 items-center justify-center">
49+
<span
50+
aria-hidden
51+
className="codu-ring-pulse absolute h-24 w-24 rounded-full"
52+
style={{ border: "2px solid rgb(var(--color-accent))" }}
53+
/>
54+
<div
55+
aria-hidden
56+
className="codu-badge-pop flex h-24 w-24 items-center justify-center bg-accent text-3xl text-on-accent"
57+
style={{ clipPath: HEX_CLIP }}
58+
>
59+
✍️
60+
</div>
61+
</div>
62+
63+
<p className="eyebrow mt-6">
64+
<span className="slash">{"// "}</span>badge unlocked
65+
</p>
66+
<h3 className="mt-2 font-display text-2xl font-extrabold tracking-tight">
67+
{badgeName}
68+
</h3>
69+
<p className="mt-2 font-mono text-sm font-semibold text-accent-soft">
70+
+{points} points
71+
</p>
72+
73+
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:justify-center">
74+
<button
75+
type="button"
76+
onClick={onClose}
77+
className="secondary-button w-full sm:w-auto"
78+
>
79+
Keep browsing
80+
</button>
81+
<Link
82+
href={badgesHref}
83+
onClick={onClose}
84+
className="primary-button w-full sm:w-auto"
85+
>
86+
See your badges
87+
</Link>
88+
</div>
89+
</div>
90+
</div>
91+
);
92+
}

components/Celebrate/Confetti.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
5+
// Design-token celebration palette (dark theme literals — pragmatic for canvas,
6+
// where CSS vars aren't directly paintable): mint accent + accent-soft, warning,
7+
// info, and white.
8+
const COLORS = ["#2dd4bf", "#6ee7d6", "#f5b544", "#5fa8f5", "#ffffff"];
9+
10+
interface Particle {
11+
x: number;
12+
y: number;
13+
vx: number;
14+
vy: number;
15+
size: number;
16+
rot: number;
17+
vr: number;
18+
color: string;
19+
}
20+
21+
/**
22+
* Lightweight one-shot confetti burst on a full-screen canvas. Particles spray
23+
* up/out from centre, fall under gravity, and fade over ~1.7s, after which the
24+
* component clears the canvas and unmounts itself. Decorative only:
25+
* pointer-events-none, aria-hidden, high z-index. Fully gated on
26+
* prefers-reduced-motion — renders nothing when the user prefers reduced motion.
27+
*/
28+
export function Confetti() {
29+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
30+
const prefersReduced =
31+
typeof window !== "undefined" &&
32+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
33+
34+
useEffect(() => {
35+
if (prefersReduced) return;
36+
const canvas = canvasRef.current;
37+
if (!canvas) return;
38+
const ctx = canvas.getContext("2d");
39+
if (!ctx) return;
40+
41+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
42+
const w = window.innerWidth;
43+
const h = window.innerHeight;
44+
canvas.width = w * dpr;
45+
canvas.height = h * dpr;
46+
ctx.scale(dpr, dpr);
47+
48+
const cx = w / 2;
49+
const cy = h * 0.42;
50+
const count = 140;
51+
const particles: Particle[] = Array.from({ length: count }, () => {
52+
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.1;
53+
const speed = 6 + Math.random() * 9;
54+
return {
55+
x: cx,
56+
y: cy,
57+
vx: Math.cos(angle) * speed,
58+
vy: Math.sin(angle) * speed,
59+
size: 4 + Math.random() * 5,
60+
rot: Math.random() * Math.PI,
61+
vr: (Math.random() - 0.5) * 0.3,
62+
color: COLORS[Math.floor(Math.random() * COLORS.length)],
63+
};
64+
});
65+
66+
const DURATION = 1700;
67+
const start = performance.now();
68+
let raf = 0;
69+
70+
const tick = (now: number) => {
71+
const elapsed = now - start;
72+
const progress = elapsed / DURATION;
73+
ctx.clearRect(0, 0, w, h);
74+
75+
if (progress >= 1) {
76+
return; // done — leave canvas cleared
77+
}
78+
79+
const alpha = progress < 0.7 ? 1 : 1 - (progress - 0.7) / 0.3;
80+
81+
for (const p of particles) {
82+
p.vy += 0.28; // gravity
83+
p.vx *= 0.99;
84+
p.x += p.vx;
85+
p.y += p.vy;
86+
p.rot += p.vr;
87+
88+
ctx.save();
89+
ctx.globalAlpha = Math.max(0, alpha);
90+
ctx.translate(p.x, p.y);
91+
ctx.rotate(p.rot);
92+
ctx.fillStyle = p.color;
93+
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
94+
ctx.restore();
95+
}
96+
97+
raf = requestAnimationFrame(tick);
98+
};
99+
100+
raf = requestAnimationFrame(tick);
101+
return () => cancelAnimationFrame(raf);
102+
}, [prefersReduced]);
103+
104+
if (prefersReduced) return null;
105+
106+
return (
107+
<canvas
108+
ref={canvasRef}
109+
aria-hidden
110+
className="pointer-events-none fixed inset-0 z-[96] h-full w-full"
111+
/>
112+
);
113+
}

components/Create/ShellActionsProvider.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@ interface ShellActions {
1313
openCompose: (mode?: ComposeMode) => void;
1414
/** Open the "edit your topics" modal. */
1515
openTopics: () => void;
16+
/** Current user's username (for profile-linked UI), or null when unknown. */
17+
username: string | null;
1618
}
1719

1820
const ShellActionsContext = createContext<ShellActions | null>(null);
1921

2022
export function useShellActions(): ShellActions {
2123
const ctx = useContext(ShellActionsContext);
2224
// Tolerate consumers rendered outside the provider (e.g. tests) with no-ops.
23-
return ctx ?? { openCompose: () => {}, openTopics: () => {} };
25+
return (
26+
ctx ?? { openCompose: () => {}, openTopics: () => {}, username: null }
27+
);
2428
}
2529

2630
/**
@@ -69,7 +73,7 @@ export function ShellActionsProvider({
6973

7074
return (
7175
<ShellActionsContext.Provider
72-
value={{ openCompose, openTopics: () => setTopicsOpen(true) }}
76+
value={{ openCompose, openTopics: () => setTopicsOpen(true), username }}
7377
>
7478
{children}
7579
{pendingInfo && (

components/Feed/OnboardingBanner.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"use client";
22

3-
import { useSyncExternalStore } from "react";
3+
import { useEffect, useState, useSyncExternalStore } from "react";
44
import Link from "next/link";
55
import { api } from "@/server/trpc/react";
66
import { useShellActions } from "@/components/Create/ShellActionsProvider";
7+
import { Confetti } from "@/components/Celebrate/Confetti";
8+
import { BadgeUnlock } from "@/components/Celebrate/BadgeUnlock";
79

810
const KEY = "codu.onboarding.dismissed";
11+
const CELEBRATED_KEY = "codu.onboarding.celebrated";
912

1013
// Tiny external store so dismissal is read without setState-in-effect and stays
1114
// SSR-safe (server snapshot = not dismissed → banner renders, client reads
@@ -18,6 +21,10 @@ const subscribe = (cb: () => void) => {
1821
const isDismissed = () =>
1922
typeof window !== "undefined" && localStorage.getItem(KEY) === "1";
2023

24+
const hasCelebrated = () =>
25+
typeof window !== "undefined" &&
26+
localStorage.getItem(CELEBRATED_KEY) === "1";
27+
2128
/**
2229
* First-run guidance: a dismissible "first win in 3 steps" banner. Steps reflect
2330
* REAL completion (topics picked / 3 follows / first post) via
@@ -26,7 +33,7 @@ const isDismissed = () =>
2633
*/
2734
export function OnboardingBanner() {
2835
const dismissed = useSyncExternalStore(subscribe, isDismissed, () => false);
29-
const { openTopics, openCompose } = useShellActions();
36+
const { openTopics, openCompose, username } = useShellActions();
3037
const { data: wins } = api.engagement.onboardingWins.useQuery();
3138

3239
// A step is { label, done, action }. Actions reuse the shell modals so they
@@ -53,7 +60,46 @@ export function OnboardingBanner() {
5360
];
5461

5562
const allDone = wins ? steps.every((s) => s.done) : false;
56-
if (dismissed || allDone) return null;
63+
64+
// Celebration single-fire. We never want this to show twice:
65+
// - across visits: a localStorage flag (read via an external store, so it's
66+
// SSR-safe and stays in sync without setState-in-effect);
67+
// - within a session: once the user closes it, `closed` stays true.
68+
// `closed` only ever flips from the onClose event handler — never from an
69+
// effect — so there's no cascading-render loop. The effect below is a pure
70+
// external-system sync (it writes localStorage, no setState).
71+
const celebrated = useSyncExternalStore(
72+
subscribe,
73+
hasCelebrated,
74+
() => true, // server snapshot: assume celebrated so SSR renders nothing
75+
);
76+
const [closed, setClosed] = useState(false);
77+
const celebrating = allDone && !celebrated && !closed;
78+
79+
useEffect(() => {
80+
if (!celebrating) return;
81+
try {
82+
localStorage.setItem(CELEBRATED_KEY, "1");
83+
} catch {
84+
// ignore storage failures
85+
}
86+
}, [celebrating]);
87+
88+
if (allDone) {
89+
return celebrating ? (
90+
<>
91+
<Confetti />
92+
<BadgeUnlock
93+
badgeName="First Post"
94+
points={20}
95+
username={username}
96+
onClose={() => setClosed(true)}
97+
/>
98+
</>
99+
) : null;
100+
}
101+
102+
if (dismissed) return null;
57103

58104
const doneCount = steps.filter((s) => s.done).length;
59105

styles/globals.css

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,3 +534,41 @@ table div {
534534
transform: translate(24px, 0);
535535
}
536536
}
537+
538+
/* Onboarding celebration — badge unlock pop + halo ring. Motion is gated on
539+
prefers-reduced-motion: no-preference, so reduced-motion users see the static
540+
dialog with no animation. */
541+
@keyframes codu-badge-pop {
542+
0% {
543+
transform: scale(0.6) rotate(-12deg);
544+
opacity: 0;
545+
}
546+
60% {
547+
transform: scale(1.08) rotate(4deg);
548+
opacity: 1;
549+
}
550+
100% {
551+
transform: scale(1) rotate(0deg);
552+
opacity: 1;
553+
}
554+
}
555+
556+
@keyframes codu-ring-pulse {
557+
0% {
558+
transform: scale(0.7);
559+
opacity: 0.6;
560+
}
561+
100% {
562+
transform: scale(1.8);
563+
opacity: 0;
564+
}
565+
}
566+
567+
@media (prefers-reduced-motion: no-preference) {
568+
.codu-badge-pop {
569+
animation: codu-badge-pop 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
570+
}
571+
.codu-ring-pulse {
572+
animation: codu-ring-pulse 1100ms ease-out 120ms infinite;
573+
}
574+
}

0 commit comments

Comments
 (0)