679 lines
24 KiB
TypeScript
679 lines
24 KiB
TypeScript
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 '@/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 '@/guest/i18n/useTranslation';
|
|
import { useLocale } from '@/guest/i18n/LocaleContext';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
import { getBentoSurfaceTokens } from '../lib/bento';
|
|
import { localizeTaskLabel } from '@/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 (
|
|
<YStack
|
|
padding={padding}
|
|
borderRadius="$bento"
|
|
backgroundColor={surface.backgroundColor}
|
|
borderWidth={1}
|
|
borderBottomWidth={3}
|
|
borderColor={surface.borderColor}
|
|
borderBottomColor={surface.borderBottomColor}
|
|
style={{ boxShadow: surface.shadow, backgroundImage: gradient }}
|
|
>
|
|
{children}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
type LeaderboardProps = {
|
|
title: string;
|
|
description: string;
|
|
icon: React.ElementType;
|
|
entries: LeaderboardEntry[];
|
|
emptyCopy: string;
|
|
formatNumber: (value: number) => string;
|
|
guestFallback: string;
|
|
};
|
|
|
|
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback }: LeaderboardProps) {
|
|
return (
|
|
<YStack gap="$2">
|
|
<XStack alignItems="center" gap="$2">
|
|
<YStack
|
|
width={34}
|
|
height={34}
|
|
borderRadius={12}
|
|
backgroundColor="rgba(244, 63, 94, 0.12)"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
<Icon size={16} color="#F43F5E" />
|
|
</YStack>
|
|
<YStack gap="$0.5">
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{title}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{description}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
{entries.length === 0 ? (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{emptyCopy}
|
|
</Text>
|
|
) : (
|
|
<YStack gap="$2">
|
|
{entries.map((entry, index) => (
|
|
<XStack
|
|
key={`${entry.guest}-${index}`}
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
padding="$2"
|
|
borderRadius="$card"
|
|
backgroundColor="rgba(15, 23, 42, 0.05)"
|
|
borderWidth={1}
|
|
borderColor="rgba(15, 23, 42, 0.08)"
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<Text fontSize="$1" fontWeight="$7" color="$color" opacity={0.7}>
|
|
#{index + 1}
|
|
</Text>
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{entry.guest || guestFallback}
|
|
</Text>
|
|
</XStack>
|
|
<XStack gap="$3">
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{formatNumber(entry.photos)}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{formatNumber(entry.likes)}
|
|
</Text>
|
|
</XStack>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
)}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
type BadgesGridProps = {
|
|
badges: AchievementBadge[];
|
|
emptyCopy: string;
|
|
completeCopy: string;
|
|
};
|
|
|
|
function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
|
|
if (badges.length === 0) {
|
|
return (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{emptyCopy}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<XStack flexWrap="wrap" gap="$2">
|
|
{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 (
|
|
<YStack
|
|
key={badge.id}
|
|
width="48%"
|
|
minWidth={140}
|
|
padding="$2"
|
|
borderRadius="$card"
|
|
borderWidth={1}
|
|
borderColor={badge.earned ? 'rgba(16, 185, 129, 0.4)' : 'rgba(15, 23, 42, 0.1)'}
|
|
backgroundColor={badge.earned ? 'rgba(16, 185, 129, 0.08)' : 'rgba(255, 255, 255, 0.85)'}
|
|
gap="$1"
|
|
>
|
|
<Text fontSize="$2" fontWeight="$7">
|
|
{badge.title}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{badge.description}
|
|
</Text>
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$1" color="$color" opacity={0.6}>
|
|
{percentage}%
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.6}>
|
|
{badge.earned ? completeCopy : `${progress}/${target}`}
|
|
</Text>
|
|
</XStack>
|
|
<YStack height={6} borderRadius={999} backgroundColor="rgba(15, 23, 42, 0.08)">
|
|
<YStack
|
|
height={6}
|
|
borderRadius={999}
|
|
backgroundColor="rgba(244, 63, 94, 0.8)"
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</YStack>
|
|
</YStack>
|
|
);
|
|
})}
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
type TimelineProps = {
|
|
points: TimelinePoint[];
|
|
formatNumber: (value: number) => string;
|
|
emptyCopy: string;
|
|
};
|
|
|
|
function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) {
|
|
if (points.length === 0) {
|
|
return (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{emptyCopy}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<YStack gap="$2">
|
|
{points.map((point) => (
|
|
<XStack
|
|
key={point.date}
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
padding="$2"
|
|
borderRadius="$card"
|
|
backgroundColor="rgba(15, 23, 42, 0.05)"
|
|
borderWidth={1}
|
|
borderColor="rgba(15, 23, 42, 0.08)"
|
|
>
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{point.date}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{formatNumber(point.photos)} photos · {formatNumber(point.guests)} guests
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
function Highlights({
|
|
topPhoto,
|
|
trendingEmotion,
|
|
formatRelativeTime,
|
|
locale,
|
|
formatNumber,
|
|
emptyCopy,
|
|
topPhotoTitle,
|
|
topPhotoNoPreview,
|
|
topPhotoFallbackGuest,
|
|
trendingTitle,
|
|
trendingCountLabel,
|
|
}: HighlightsProps) {
|
|
if (!topPhoto && !trendingEmotion) {
|
|
return (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{emptyCopy}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<YStack gap="$3">
|
|
{topPhoto ? (
|
|
<YStack gap="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{topPhotoTitle}
|
|
</Text>
|
|
<Trophy size={18} color="#FBBF24" />
|
|
</XStack>
|
|
<YStack gap="$2">
|
|
{topPhoto.thumbnail ? (
|
|
<img
|
|
src={topPhoto.thumbnail}
|
|
alt="Top photo"
|
|
style={{ width: '100%', height: 180, objectFit: 'cover', borderRadius: 16 }}
|
|
/>
|
|
) : (
|
|
<YStack height={180} borderRadius={16} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{topPhotoNoPreview}
|
|
</Text>
|
|
</YStack>
|
|
)}
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{topPhoto.guest || topPhotoFallbackGuest}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{formatNumber(topPhoto.likes)} likes · {formatRelativeTime(topPhoto.createdAt)}
|
|
</Text>
|
|
{topPhoto.task ? (
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{localizeTaskLabel(topPhoto.task ?? null, locale) ?? topPhoto.task}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{trendingEmotion ? (
|
|
<YStack gap="$2">
|
|
<XStack alignItems="center" justifyContent="space-between">
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{trendingTitle}
|
|
</Text>
|
|
<Flame size={18} color="#F43F5E" />
|
|
</XStack>
|
|
<Text fontSize="$4" fontWeight="$8">
|
|
{trendingEmotion.name}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{trendingCountLabel.replace('{count}', formatNumber(trendingEmotion.count))}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
type FeedProps = {
|
|
feed: FeedEntry[];
|
|
formatRelativeTime: (value: string) => string;
|
|
locale: string;
|
|
formatNumber: (value: number) => string;
|
|
emptyCopy: string;
|
|
guestFallback: string;
|
|
likesLabel: string;
|
|
};
|
|
|
|
function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel }: FeedProps) {
|
|
if (feed.length === 0) {
|
|
return (
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{emptyCopy}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<YStack gap="$3">
|
|
{feed.map((item) => {
|
|
const taskLabel = localizeTaskLabel(item.task ?? null, locale);
|
|
return (
|
|
<XStack
|
|
key={item.photoId}
|
|
gap="$2"
|
|
padding="$2"
|
|
borderRadius="$card"
|
|
backgroundColor="rgba(15, 23, 42, 0.05)"
|
|
borderWidth={1}
|
|
borderColor="rgba(15, 23, 42, 0.08)"
|
|
>
|
|
{item.thumbnail ? (
|
|
<img
|
|
src={item.thumbnail}
|
|
alt="Feed thumbnail"
|
|
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 12 }}
|
|
/>
|
|
) : (
|
|
<YStack width={64} height={64} borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
|
|
<Camera size={18} color="#0F172A" />
|
|
</YStack>
|
|
)}
|
|
<YStack flex={1} gap="$1">
|
|
<Text fontSize="$2" fontWeight="$7">
|
|
{item.guest || guestFallback}
|
|
</Text>
|
|
{taskLabel ? (
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{taskLabel}
|
|
</Text>
|
|
) : null}
|
|
<XStack gap="$3">
|
|
<Text fontSize="$1" color="$color" opacity={0.6}>
|
|
{formatRelativeTime(item.createdAt)}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.6}>
|
|
{likesLabel.replace('{count}', formatNumber(item.likes))}
|
|
</Text>
|
|
</XStack>
|
|
</YStack>
|
|
</XStack>
|
|
);
|
|
})}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
export default function AchievementsScreen() {
|
|
const { token, tasksEnabled } = useEventData();
|
|
const identity = useOptionalGuestIdentity();
|
|
const { t } = useTranslation();
|
|
const { locale } = useLocale();
|
|
const { isDark } = useGuestThemeVariant();
|
|
const navigate = useNavigate();
|
|
const surface = getBentoSurfaceTokens(isDark);
|
|
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<AchievementsPayload | null>(null);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = React.useState<TabKey>('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 = (
|
|
<YStack gap="$4" paddingBottom={100}>
|
|
<BentoCard isDark={isDark} padding={20} gradient={headerGradient}>
|
|
<XStack alignItems="center" gap="$2">
|
|
<YStack
|
|
width={40}
|
|
height={40}
|
|
borderRadius={14}
|
|
backgroundColor="rgba(244, 63, 94, 0.16)"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
>
|
|
<Award size={18} color="#F43F5E" />
|
|
</YStack>
|
|
<YStack gap="$0.5" flexShrink={1} maxWidth="100%">
|
|
<Text fontSize="$5" fontWeight="$8">
|
|
{t('achievements.page.title', 'Achievements')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7} flexWrap="wrap">
|
|
{t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
</BentoCard>
|
|
|
|
{summary ? (
|
|
<XStack gap="$2" flexWrap="nowrap" overflow="hidden">
|
|
{[
|
|
{ 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) => (
|
|
<YStack key={item.label} flex={1} minWidth={0}>
|
|
<BentoCard isDark={isDark} padding={10}>
|
|
<Text fontSize="$3" fontWeight="$8">
|
|
{formatNumber(item.value)}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7} numberOfLines={2}>
|
|
{item.label}
|
|
</Text>
|
|
</BentoCard>
|
|
</YStack>
|
|
))}
|
|
</XStack>
|
|
) : null}
|
|
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
{([
|
|
{ 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) => (
|
|
<Button
|
|
key={tab.key}
|
|
size="$3"
|
|
borderRadius="$pill"
|
|
backgroundColor={activeTab === tab.key ? '$primary' : mutedButton}
|
|
borderWidth={1}
|
|
borderColor={activeTab === tab.key ? '$primary' : mutedButtonBorder}
|
|
onPress={() => setActiveTab(tab.key)}
|
|
disabled={tab.disabled}
|
|
opacity={tab.disabled ? 0.5 : 1}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
<tab.icon size={14} color={activeTab === tab.key ? '#FFFFFF' : isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$7" color={activeTab === tab.key ? '#FFFFFF' : undefined}>
|
|
{tab.label}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
))}
|
|
</XStack>
|
|
|
|
{loading ? (
|
|
<YStack gap="$3">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<BentoCard key={`loading-${index}`} isDark={isDark}>
|
|
<YStack height={56} />
|
|
</BentoCard>
|
|
))}
|
|
</YStack>
|
|
) : error ? (
|
|
<BentoCard isDark={isDark}>
|
|
<Text fontSize="$2" color={isDark ? '#FCA5A5' : '#DC2626'}>
|
|
{error === GENERIC_ERROR
|
|
? t('achievements.page.loadError', 'Achievements could not be loaded.')
|
|
: error}
|
|
</Text>
|
|
</BentoCard>
|
|
) : payload ? (
|
|
<YStack gap="$4">
|
|
{activeTab === 'personal' ? (
|
|
<YStack gap="$4">
|
|
<BentoCard isDark={isDark}>
|
|
<YStack gap="$2">
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{t('achievements.badges.title', 'Badges')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('achievements.badges.description', 'Unlock achievements and keep your streak going.')}
|
|
</Text>
|
|
<BadgesGrid
|
|
badges={personal?.badges ?? []}
|
|
emptyCopy={t('achievements.badges.empty', 'No badges yet.')}
|
|
completeCopy={t('achievements.badges.complete', 'Complete')}
|
|
/>
|
|
</YStack>
|
|
</BentoCard>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{activeTab === 'event' ? (
|
|
<YStack gap="$4">
|
|
<BentoCard isDark={isDark}>
|
|
<Highlights
|
|
topPhoto={highlights?.topPhoto ?? null}
|
|
trendingEmotion={highlights?.trendingEmotion ?? null}
|
|
formatRelativeTime={formatRelative}
|
|
locale={locale}
|
|
formatNumber={formatNumber}
|
|
emptyCopy={t('achievements.highlights.empty', 'Highlights will appear as soon as more photos are shared.')}
|
|
topPhotoTitle={t('achievements.highlights.topTitle', 'Top photo')}
|
|
topPhotoNoPreview={t('achievements.highlights.noPreview', 'No preview')}
|
|
topPhotoFallbackGuest={t('achievements.leaderboard.guestFallback', 'Guest')}
|
|
trendingTitle={t('achievements.highlights.trendingTitle', 'Trending emotion')}
|
|
trendingCountLabel={t('achievements.highlights.trendingCount', { count: '{count}' }, '{count} photos')}
|
|
/>
|
|
</BentoCard>
|
|
|
|
<XStack gap="$3" flexWrap="wrap">
|
|
<YStack flex={1} minWidth={240} gap="$3">
|
|
<BentoCard isDark={isDark}>
|
|
<Text fontSize="$3" fontWeight="$7">
|
|
{t('achievements.timeline.title', 'Timeline')}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
|
{t('achievements.timeline.description', 'Daily momentum snapshot.')}
|
|
</Text>
|
|
<Timeline
|
|
points={highlights?.timeline ?? []}
|
|
formatNumber={formatNumber}
|
|
emptyCopy={t('achievements.timeline.empty', 'No timeline data yet.')}
|
|
/>
|
|
</BentoCard>
|
|
</YStack>
|
|
<YStack flex={1} minWidth={240} gap="$3">
|
|
<BentoCard isDark={isDark}>
|
|
<Leaderboard
|
|
title={t('achievements.leaderboard.uploadsTitle', 'Top uploads')}
|
|
description={t('achievements.leaderboard.description', 'Most photos shared.')}
|
|
icon={Users}
|
|
entries={leaderboards?.uploads ?? []}
|
|
emptyCopy={t('achievements.leaderboard.uploadsEmpty', 'No uploads yet.')}
|
|
formatNumber={formatNumber}
|
|
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
|
/>
|
|
</BentoCard>
|
|
<BentoCard isDark={isDark}>
|
|
<Leaderboard
|
|
title={t('achievements.leaderboard.likesTitle', 'Top likes')}
|
|
description={t('achievements.leaderboard.description', 'Most loved moments.')}
|
|
icon={Trophy}
|
|
entries={leaderboards?.likes ?? []}
|
|
emptyCopy={t('achievements.leaderboard.likesEmpty', 'No likes yet.')}
|
|
formatNumber={formatNumber}
|
|
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
|
/>
|
|
</BentoCard>
|
|
</YStack>
|
|
</XStack>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{activeTab === 'feed' ? (
|
|
<BentoCard isDark={isDark}>
|
|
<Feed
|
|
feed={feed}
|
|
formatRelativeTime={formatRelative}
|
|
locale={locale}
|
|
formatNumber={formatNumber}
|
|
emptyCopy={t('achievements.feed.empty', 'The feed is quiet for now.')}
|
|
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
|
likesLabel={t('achievements.feed.likesLabel', { count: '{count}' }, '{count} likes')}
|
|
/>
|
|
</BentoCard>
|
|
) : null}
|
|
|
|
</YStack>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
|
|
return (
|
|
<AppShell>
|
|
<PullToRefresh
|
|
onRefresh={() => loadAchievements(true)}
|
|
pullLabel={t('common.pullToRefresh')}
|
|
releaseLabel={t('common.releaseToRefresh')}
|
|
refreshingLabel={t('common.refreshing')}
|
|
>
|
|
{content}
|
|
</PullToRefresh>
|
|
</AppShell>
|
|
);
|
|
}
|