| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| type ConfettiFn = typeof import('canvas-confetti'); |
| type ConfettiOptions = Parameters<ConfettiFn>[0]; |
| let confettiPromise: Promise<ConfettiFn> | null = null; |
| function loadConfetti(): Promise<ConfettiFn> { |
| if (!confettiPromise) { |
| confettiPromise = import('canvas-confetti') |
| |
| |
| .then((m) => ((m as { default?: ConfettiFn }).default ?? m) as ConfettiFn) |
| .catch((err) => { confettiPromise = null; throw err; }); |
| } |
| return confettiPromise; |
| } |
| function fireConfetti(options: ConfettiOptions): void { |
| void loadConfetti().then((confetti) => { confetti(options); }).catch(() => { }); |
| } |
|
|
| |
|
|
| export interface MilestoneData { |
| speciesRecoveries?: Array<{ name: string; status: string }>; |
| renewablePercent?: number; |
| newSpeciesCount?: number; |
| } |
|
|
| |
|
|
| |
| const REDUCED_MOTION = typeof window !== 'undefined' |
| ? window.matchMedia('(prefers-reduced-motion: reduce)').matches |
| : false; |
|
|
| |
| const WARM_COLORS = ['#6B8F5E', '#C4A35A', '#7BA5C4', '#8BAF7A', '#E8B96E', '#7FC4C4']; |
|
|
| |
| const celebrated = new Set<string>(); |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| export function celebrate(type: 'milestone' | 'record' = 'milestone'): void { |
| if (REDUCED_MOTION) return; |
|
|
| if (type === 'milestone') { |
| fireConfetti({ |
| particleCount: 40, |
| spread: 60, |
| origin: { y: 0.7 }, |
| colors: WARM_COLORS, |
| disableForReducedMotion: true, |
| }); |
| } else { |
| |
| fireConfetti({ |
| particleCount: 80, |
| spread: 90, |
| origin: { y: 0.6 }, |
| colors: WARM_COLORS, |
| disableForReducedMotion: true, |
| }); |
| setTimeout(() => { |
| fireConfetti({ |
| particleCount: 80, |
| spread: 90, |
| origin: { y: 0.6 }, |
| colors: WARM_COLORS, |
| disableForReducedMotion: true, |
| }); |
| }, 300); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function checkMilestones(data: MilestoneData): void { |
| |
| if (data.speciesRecoveries) { |
| for (const species of data.speciesRecoveries) { |
| const status = species.status.toLowerCase(); |
| if (status === 'recovered' || status === 'stabilized') { |
| const key = `species:${species.name}`; |
| if (!celebrated.has(key)) { |
| celebrated.add(key); |
| celebrate('milestone'); |
| return; |
| } |
| } |
| } |
| } |
|
|
| |
| if (data.renewablePercent != null && data.renewablePercent > 0) { |
| const threshold = Math.floor(data.renewablePercent / 5) * 5; |
| const key = `renewable:${threshold}`; |
| if (!celebrated.has(key)) { |
| celebrated.add(key); |
| celebrate('record'); |
| return; |
| } |
| } |
|
|
| |
| if (data.newSpeciesCount != null && data.newSpeciesCount > 0) { |
| const key = `species-count:${data.newSpeciesCount}`; |
| if (!celebrated.has(key)) { |
| celebrated.add(key); |
| celebrate('milestone'); |
| return; |
| } |
| } |
| } |
|
|
| |
| |
| |
| export function resetCelebrations(): void { |
| celebrated.clear(); |
| } |
|
|