import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Award, BarChart2, Camera, Flame, Sparkles, Trophy, Users } from 'lucide-react'; import AppShell from '../components/AppShell'; import PullToRefresh from '@/shared/guest/components/PullToRefresh'; import { useEventData } from '../context/EventDataContext'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { fetchAchievements, type AchievementBadge, type AchievementsPayload, type FeedEntry, type LeaderboardEntry, type TimelinePoint, type TopPhotoHighlight, type TrendingEmotionHighlight, } from '../services/achievementsApi'; import { useTranslation } from '@/shared/guest/i18n/useTranslation'; import { useLocale } from '@/shared/guest/i18n/LocaleContext'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { getBentoSurfaceTokens } from '../lib/bento'; import { localizeTaskLabel } from '@/shared/guest/lib/localizeTaskLabel'; import { buildEventPath } from '../lib/routes'; import { useNavigate } from 'react-router-dom'; const GENERIC_ERROR = 'GENERIC_ERROR'; type TabKey = 'personal' | 'event' | 'feed'; type BentoCardProps = { children: React.ReactNode; isDark: boolean; padding?: number; gradient?: string; }; function formatRelativeTimestamp(input: string, formatter: Intl.RelativeTimeFormat): string { const date = new Date(input); if (Number.isNaN(date.getTime())) return ''; const diff = date.getTime() - Date.now(); const minute = 60_000; const hour = 60 * minute; const day = 24 * hour; const abs = Math.abs(diff); if (abs < minute) return formatter.format(0, 'second'); if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute'); if (abs < day) return formatter.format(Math.round(diff / hour), 'hour'); return formatter.format(Math.round(diff / day), 'day'); } function BentoCard({ children, isDark, padding = 16, gradient }: BentoCardProps) { const surface = getBentoSurfaceTokens(isDark); return ( {children} ); } type LeaderboardProps = { title: string; description: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string; formatNumber: (value: number) => string; guestFallback: string; isDark: boolean; }; function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback, isDark }: LeaderboardProps) { return ( {title} {description} {entries.length === 0 ? ( {emptyCopy} ) : ( {entries.map((entry, index) => ( #{index + 1} {entry.guest || guestFallback} {formatNumber(entry.photos)} {formatNumber(entry.likes)} ))} )} ); } type BadgesGridProps = { badges: AchievementBadge[]; emptyCopy: string; completeCopy: string; isDark: boolean; }; function BadgesGrid({ badges, emptyCopy, completeCopy, isDark }: BadgesGridProps) { if (badges.length === 0) { return ( {emptyCopy} ); } return ( {badges.map((badge) => { const target = badge.target ?? 0; const progress = badge.progress ?? 0; const ratio = target > 0 ? Math.min(1, progress / target) : 0; const percentage = Math.round((badge.earned ? 1 : ratio) * 100); return ( {badge.title} {badge.description} {percentage}% {badge.earned ? completeCopy : `${progress}/${target}`} ); })} ); } type TimelineProps = { points: TimelinePoint[]; formatNumber: (value: number) => string; emptyCopy: string; isDark: boolean; }; function Timeline({ points, formatNumber, emptyCopy, isDark }: TimelineProps) { if (points.length === 0) { return ( {emptyCopy} ); } return ( {points.map((point) => ( {point.date} {formatNumber(point.photos)} photos · {formatNumber(point.guests)} guests ))} ); } type HighlightsProps = { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null; formatRelativeTime: (value: string) => string; locale: string; formatNumber: (value: number) => string; emptyCopy: string; topPhotoTitle: string; topPhotoNoPreview: string; topPhotoFallbackGuest: string; trendingTitle: string; trendingCountLabel: string; isDark: boolean; }; function Highlights({ topPhoto, trendingEmotion, formatRelativeTime, locale, formatNumber, emptyCopy, topPhotoTitle, topPhotoNoPreview, topPhotoFallbackGuest, trendingTitle, trendingCountLabel, isDark, }: HighlightsProps) { if (!topPhoto && !trendingEmotion) { return ( {emptyCopy} ); } return ( {topPhoto ? ( {topPhotoTitle} {topPhoto.thumbnail ? ( Top photo ) : ( {topPhotoNoPreview} )} {topPhoto.guest || topPhotoFallbackGuest} {formatNumber(topPhoto.likes)} likes · {formatRelativeTime(topPhoto.createdAt)} {topPhoto.task ? ( {localizeTaskLabel(topPhoto.task ?? null, locale) ?? topPhoto.task} ) : null} ) : null} {trendingEmotion ? ( {trendingTitle} {trendingEmotion.name} {trendingCountLabel.replace('{count}', formatNumber(trendingEmotion.count))} ) : null} ); } type FeedProps = { feed: FeedEntry[]; formatRelativeTime: (value: string) => string; locale: string; formatNumber: (value: number) => string; emptyCopy: string; guestFallback: string; likesLabel: string; isDark: boolean; }; function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel, isDark }: FeedProps) { if (feed.length === 0) { return ( {emptyCopy} ); } return ( {feed.map((item) => { const taskLabel = localizeTaskLabel(item.task ?? null, locale); return ( {item.thumbnail ? ( Feed thumbnail ) : ( )} {item.guest || guestFallback} {taskLabel ? ( {taskLabel} ) : null} {formatRelativeTime(item.createdAt)} {likesLabel.replace('{count}', formatNumber(item.likes))} ); })} ); } export default function AchievementsScreen() { const { token, tasksEnabled } = useEventData(); const identity = useOptionalGuestIdentity(); const { t } = useTranslation(); const { locale } = useLocale(); const { isDark } = useGuestThemeVariant(); const navigate = useNavigate(); const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; const [payload, setPayload] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [activeTab, setActiveTab] = React.useState('personal'); const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); const formatNumber = (value: number) => numberFormatter.format(value); const relativeFormatter = React.useMemo(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }), [locale]); const formatRelative = (value: string) => formatRelativeTimestamp(value, relativeFormatter); const loadAchievements = React.useCallback(async (forceRefresh = false) => { if (!token) return; setLoading(true); setError(null); try { const data = await fetchAchievements(token, { guestName: identity?.name ?? undefined, locale, forceRefresh, }); setPayload(data); setActiveTab(data.personal ? 'personal' : 'event'); } catch (err) { console.error('Failed to load achievements', err); setError(err instanceof Error ? err.message : GENERIC_ERROR); } finally { setLoading(false); } }, [identity?.name, locale, token]); React.useEffect(() => { loadAchievements(); }, [loadAchievements]); if (!token) { return null; } const hasPersonal = Boolean(payload?.personal); const summary = payload?.summary; const personal = payload?.personal ?? null; const leaderboards = payload?.leaderboards ?? null; const highlights = payload?.highlights ?? null; const feed = payload?.feed ?? []; const headerGradient = isDark ? 'radial-gradient(120% 120% at 20% 20%, rgba(79, 209, 255, 0.16), transparent 60%), radial-gradient(120% 120% at 80% 0%, rgba(244, 63, 94, 0.18), transparent 60%)' : 'radial-gradient(140% 140% at 20% 10%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 22%, white), transparent 55%), radial-gradient(120% 120% at 80% 0%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 22%, white), transparent 60%)'; const content = ( {t('achievements.page.title', 'Achievements')} {t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')} {summary ? ( {[ { label: t('achievements.summary.photosShared', 'Photos shared'), value: summary.totalPhotos }, ...(tasksEnabled ? [{ label: t('achievements.summary.tasksCompleted', 'Tasks completed'), value: summary.tasksSolved }] : []), { label: t('achievements.summary.likesCollected', 'Likes collected'), value: summary.likesTotal }, { label: t('achievements.summary.uniqueGuests', 'Guests involved'), value: summary.uniqueGuests }, ].map((item) => ( {formatNumber(item.value)} {item.label} ))} ) : null} {([ { key: 'personal', label: t('achievements.page.buttons.personal', 'Personal'), icon: Sparkles, disabled: !hasPersonal }, { key: 'event', label: t('achievements.page.buttons.event', 'Event'), icon: Users, disabled: false }, { key: 'feed', label: t('achievements.page.buttons.feed', 'Feed'), icon: BarChart2, disabled: false }, ] as const).map((tab) => ( ))} {loading ? ( {Array.from({ length: 3 }).map((_, index) => ( ))} ) : error ? ( {error === GENERIC_ERROR ? t('achievements.page.loadError', 'Achievements could not be loaded.') : error} ) : payload ? ( {activeTab === 'personal' ? ( {t('achievements.badges.title', 'Badges')} {t('achievements.badges.description', 'Unlock achievements and keep your streak going.')} ) : null} {activeTab === 'event' ? ( {t('achievements.timeline.title', 'Timeline')} {t('achievements.timeline.description', 'Daily momentum snapshot.')} ) : null} {activeTab === 'feed' ? ( ) : null} ) : null} ); return ( loadAchievements(true)} pullLabel={t('common.pullToRefresh')} releaseLabel={t('common.releaseToRefresh')} refreshingLabel={t('common.refreshing')} > {content} ); }