feat(org): add celebration confetti when joining or creating organization

Add a celebratory experience when users join or create an organization:
- Add canvas-confetti dependency for visual effects
- Store organization name in localStorage after successful join/create
- Display celebration dialog with confetti animation on ProfilePage
- Clear the celebration flag after showing to prevent repeat displays
This commit is contained in:
2026-04-21 17:11:05 +09:30
parent 5fc24b7a42
commit 78ac65169e
5 changed files with 73 additions and 5 deletions
+11
View File
@@ -37,6 +37,7 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -3955,6 +3956,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/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+1
View File
@@ -40,6 +40,7 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+4
View File
@@ -63,6 +63,10 @@ export default function InviteAcceptPage() {
// Store the token manually since we're not using the normal login flow
localStorage.setItem("secuird_token", result.token);
}
// Store org name for celebration on ProfilePage
if (inviteData?.organization?.name) {
localStorage.setItem('justJoinedOrg', inviteData.organization.name);
}
await refreshUser();
await checkOrgAdmin();
navigate("/profile");
+7 -4
View File
@@ -99,11 +99,14 @@ export default function OrgSetupPage() {
setOrgSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
};
const done = async () => {
const done = async (orgName?: string) => {
await refreshUser();
await checkOrgAdmin();
queryClient.invalidateQueries({ queryKey: ['organizations'] });
navigate("/profile", { replace: true });
if (orgName) {
localStorage.setItem('justJoinedOrg', orgName);
}
navigate('/profile', { replace: true });
};
// ── Accept an invite ───────────────────────────────────────────────────────
@@ -113,7 +116,7 @@ export default function OrgSetupPage() {
try {
const result = await api.invites.accept(invite.token);
if (result.token) tokenManager.setToken(result.token, result.expires_at ?? null);
await done();
await done(invite.organization.name);
} catch (err) {
setJoinError(err instanceof ApiError ? err.message : "Failed to join organisation. Please try again.");
setJoiningToken(null);
@@ -129,7 +132,7 @@ export default function OrgSetupPage() {
setIsCreating(true);
try {
await api.organizations.create(orgName.trim(), orgSlug.trim());
await done();
await done(orgName.trim());
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Failed to create organisation. Please try again.");
} finally {
+50 -1
View File
@@ -18,7 +18,21 @@ import {
import { useAuth } from "@/contexts/AuthContext";
import { api, ApiError, PendingInvite } from "@/lib/api";
import { toast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import confetti from "canvas-confetti";
// Wrapper to handle confetti failures gracefully
const fireConfetti = () => {
try {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
} catch (e) {
console.warn('Confetti failed:', e);
}
};
function ProfileSkeleton() {
return (
@@ -68,6 +82,7 @@ function ProfileSkeleton() {
export default function ProfilePage() {
const { user, isLoading: authLoading, refreshUser, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [name, setName] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -79,6 +94,10 @@ export default function ProfilePage() {
const [isDeleting, setIsDeleting] = useState(false);
const [confirmEmail, setConfirmEmail] = useState("");
// Celebration dialog state
const [showCelebration, setShowCelebration] = useState(false);
const [celebrationOrgName, setCelebrationOrgName] = useState("");
// Sync local name state with user data
useEffect(() => {
if (user?.full_name) {
@@ -86,6 +105,17 @@ export default function ProfilePage() {
}
}, [user?.full_name]);
// Check for org creation/join celebration
useEffect(() => {
const fromStorage = localStorage.getItem('justJoinedOrg');
if (fromStorage) {
setCelebrationOrgName(fromStorage);
setShowCelebration(true);
fireConfetti();
localStorage.removeItem('justJoinedOrg');
}
}, [location.pathname]);
// Fetch pending invitations for this user
useEffect(() => {
if (!user) return;
@@ -477,6 +507,25 @@ export default function ProfilePage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Celebration dialog for org creation/join */}
<Dialog open={showCelebration} onOpenChange={setShowCelebration}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-center text-xl">
🎉 Congratulations!
</DialogTitle>
<DialogDescription className="text-center text-base">
You've joined <span className="font-semibold">{celebrationOrgName}</span>!
</DialogDescription>
</DialogHeader>
<div className="flex justify-center py-4">
<Button onClick={() => setShowCelebration(false)}>
Get Started
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}