573 lines
22 KiB
TypeScript
573 lines
22 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { Link, useNavigationType, useParams } from 'react-router-dom';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { cn } from '@/lib/utils';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import { AnimatePresence, motion } from 'framer-motion';
|
||
import {
|
||
AchievementBadge,
|
||
AchievementsPayload,
|
||
FeedEntry,
|
||
LeaderboardEntry,
|
||
TimelinePoint,
|
||
TopPhotoHighlight,
|
||
TrendingEmotionHighlight,
|
||
fetchAchievements,
|
||
} from '../services/achievementApi';
|
||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||
import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react';
|
||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||
import type { LocaleCode } from '../i18n/messages';
|
||
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
||
import { useEventData } from '../hooks/useEventData';
|
||
import { isTaskModeEnabled } from '../lib/engagement';
|
||
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
|
||
import PullToRefresh from '../components/PullToRefresh';
|
||
|
||
const GENERIC_ERROR = 'GENERIC_ERROR';
|
||
|
||
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');
|
||
}
|
||
|
||
type LeaderboardProps = {
|
||
title: string;
|
||
description: string;
|
||
icon: React.ElementType;
|
||
entries: LeaderboardEntry[];
|
||
emptyCopy: string;
|
||
formatNumber: (value: number) => string;
|
||
t: TranslateFn;
|
||
};
|
||
|
||
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, t }: LeaderboardProps) {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center gap-2 pb-4">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||
<CardDescription className="text-xs">{description}</CardDescription>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{entries.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{emptyCopy}</p>
|
||
) : (
|
||
<ol className="space-y-2 text-sm">
|
||
{entries.map((entry, index) => (
|
||
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/60 bg-card/70 px-3 py-2">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs font-semibold text-muted-foreground">#{index + 1}</span>
|
||
<span className="font-medium text-foreground">{entry.guest || t('achievements.leaderboard.guestFallback')}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||
<span>{t('achievements.leaderboard.item.photos', { count: formatNumber(entry.photos) })}</span>
|
||
<span>{t('achievements.leaderboard.item.likes', { count: formatNumber(entry.likes) })}</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
type BadgesGridProps = {
|
||
badges: AchievementBadge[];
|
||
t: TranslateFn;
|
||
};
|
||
|
||
export function BadgesGrid({ badges, t }: BadgesGridProps) {
|
||
if (badges.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('achievements.badges.title')}</CardTitle>
|
||
<CardDescription>{t('achievements.badges.description')}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">{t('achievements.badges.empty')}</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('achievements.badges.title')}</CardTitle>
|
||
<CardDescription>{t('achievements.badges.description')}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||
{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 (
|
||
<div
|
||
key={badge.id}
|
||
data-testid={`badge-card-${badge.id}`}
|
||
className={cn(
|
||
'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition',
|
||
badge.earned
|
||
? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900 dark:border-emerald-400/30 dark:from-emerald-400/20 dark:via-emerald-400/10 dark:to-slate-950/70 dark:text-emerald-50'
|
||
: 'border-border/60 bg-card/90',
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<p className="text-sm font-semibold text-foreground">{badge.title}</p>
|
||
<p className="text-xs text-muted-foreground">{badge.description}</p>
|
||
</div>
|
||
<span className="text-xs font-semibold text-muted-foreground">{percentage}%</span>
|
||
</div>
|
||
<div className="mt-3 h-2 rounded-full bg-muted">
|
||
<div className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500" style={{ width: `${percentage}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
type TimelineProps = {
|
||
points: TimelinePoint[];
|
||
t: TranslateFn;
|
||
formatNumber: (value: number) => string;
|
||
};
|
||
|
||
function Timeline({ points, t, formatNumber }: TimelineProps) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('achievements.timeline.title')}</CardTitle>
|
||
<CardDescription>{t('achievements.timeline.description')}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
{points.map((point) => (
|
||
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/60 bg-card/70 px-3 py-2">
|
||
<span className="font-medium text-foreground">{point.date}</span>
|
||
<span className="text-muted-foreground">
|
||
{t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
type FeedProps = {
|
||
feed: FeedEntry[];
|
||
t: TranslateFn;
|
||
formatRelativeTime: (value: string) => string;
|
||
locale: LocaleCode;
|
||
formatNumber: (value: number) => string;
|
||
};
|
||
|
||
function Feed({ feed, t, formatRelativeTime, locale, formatNumber }: FeedProps) {
|
||
if (feed.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('achievements.feed.title')}</CardTitle>
|
||
<CardDescription>{t('achievements.feed.description')}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">{t('achievements.feed.empty')}</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t('achievements.feed.title')}</CardTitle>
|
||
<CardDescription>{t('achievements.feed.description')}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{feed.map((item) => {
|
||
const taskLabel = localizeTaskLabel(item.task ?? null, locale);
|
||
return (
|
||
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/70 p-3">
|
||
{item.thumbnail ? (
|
||
<img src={item.thumbnail} alt={t('achievements.feed.thumbnailAlt')} className="h-16 w-16 rounded-md object-cover" />
|
||
) : (
|
||
<div className="flex h-16 w-16 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||
<Camera className="h-6 w-6" aria-hidden />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 text-sm">
|
||
<p className="font-semibold text-foreground">{item.guest || t('achievements.leaderboard.guestFallback')}</p>
|
||
{taskLabel && <p className="text-xs text-muted-foreground">{t('achievements.feed.taskLabel', { task: taskLabel })}</p>}
|
||
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
|
||
<span>{formatRelativeTime(item.createdAt)}</span>
|
||
<span>{t('achievements.feed.likesLabel', { count: formatNumber(item.likes) })}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
type HighlightsProps = {
|
||
topPhoto: TopPhotoHighlight | null;
|
||
trendingEmotion: TrendingEmotionHighlight | null;
|
||
t: TranslateFn;
|
||
formatRelativeTime: (value: string) => string;
|
||
locale: LocaleCode;
|
||
formatNumber: (value: number) => string;
|
||
};
|
||
|
||
function Highlights({ topPhoto, trendingEmotion, t, formatRelativeTime, locale, formatNumber }: HighlightsProps) {
|
||
if (!topPhoto && !trendingEmotion) {
|
||
return null;
|
||
}
|
||
|
||
const renderTopPhoto = () => {
|
||
if (!topPhoto) return null;
|
||
const localizedTask = localizeTaskLabel(topPhoto.task ?? null, locale);
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>{t('achievements.highlights.topTitle')}</CardTitle>
|
||
<CardDescription>{t('achievements.highlights.topDescription')}</CardDescription>
|
||
</div>
|
||
<Trophy className="h-6 w-6 text-amber-400" aria-hidden />
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
<div className="overflow-hidden rounded-xl border border-border/40">
|
||
{topPhoto.thumbnail ? (
|
||
<img src={topPhoto.thumbnail} alt={t('achievements.highlights.topTitle')} className="h-48 w-full object-cover" />
|
||
) : (
|
||
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">
|
||
{t('achievements.highlights.noPreview')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p>
|
||
<span className="font-semibold text-foreground">{topPhoto.guest || t('achievements.leaderboard.guestFallback')}</span>
|
||
{` – ${t('achievements.highlights.likesAmount', { count: formatNumber(topPhoto.likes) })}`}
|
||
</p>
|
||
{localizedTask && (
|
||
<p className="text-muted-foreground">
|
||
{t('achievements.highlights.taskLabel', { task: localizedTask })}
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
const renderTrendingEmotion = () => {
|
||
if (!trendingEmotion) return null;
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>{t('achievements.highlights.trendingTitle')}</CardTitle>
|
||
<CardDescription>{t('achievements.highlights.trendingDescription')}</CardDescription>
|
||
</div>
|
||
<Flame className="h-6 w-6 text-pink-500" aria-hidden />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-lg font-semibold text-foreground">{trendingEmotion.name}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t('achievements.highlights.trendingCount', { count: formatNumber(trendingEmotion.count) })}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{renderTopPhoto()}
|
||
{renderTrendingEmotion()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type PersonalActionsProps = {
|
||
token: string;
|
||
t: TranslateFn;
|
||
tasksEnabled: boolean;
|
||
};
|
||
|
||
function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) {
|
||
return (
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button asChild>
|
||
<Link to={`/e/${encodeURIComponent(token)}/upload`} className="flex items-center gap-2">
|
||
<Camera className="h-4 w-4" aria-hidden />
|
||
{t('achievements.personal.actions.upload')}
|
||
</Link>
|
||
</Button>
|
||
{tasksEnabled ? (
|
||
<Button variant="outline" asChild>
|
||
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
|
||
<Sparkles className="h-4 w-4" aria-hidden />
|
||
{t('achievements.personal.actions.tasks')}
|
||
</Link>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function AchievementsPage() {
|
||
const { token } = useParams<{ token: string }>();
|
||
const navigationType = useNavigationType();
|
||
const identity = useGuestIdentity();
|
||
const { t, locale } = useTranslation();
|
||
const { event } = useEventData();
|
||
const tasksEnabled = isTaskModeEnabled(event);
|
||
const [data, setData] = useState<AchievementsPayload | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal');
|
||
|
||
const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||
const formatNumber = (value: number) => numberFormatter.format(value);
|
||
const relativeFormatter = useMemo(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }), [locale]);
|
||
const formatRelative = (value: string) => formatRelativeTimestamp(value, relativeFormatter);
|
||
|
||
const personalName = identity.hydrated && identity.name ? identity.name : undefined;
|
||
|
||
const loadAchievements = React.useCallback(async (signal?: AbortSignal) => {
|
||
if (!token) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const payload = await fetchAchievements(token, {
|
||
guestName: personalName,
|
||
locale,
|
||
signal,
|
||
});
|
||
setData(payload);
|
||
if (!payload.personal) {
|
||
setActiveTab('event');
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||
console.error('Failed to load achievements', err);
|
||
setError(err instanceof Error ? err.message : GENERIC_ERROR);
|
||
} finally {
|
||
if (!signal?.aborted) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [locale, personalName, token]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
void loadAchievements(controller.signal);
|
||
return () => controller.abort();
|
||
}, [loadAchievements]);
|
||
|
||
const hasPersonal = Boolean(data?.personal);
|
||
const motionEnabled = !prefersReducedMotion();
|
||
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
|
||
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
|
||
const tabMotion = motionEnabled
|
||
? { variants: FADE_UP, initial: 'hidden', animate: 'show', exit: 'hidden' as const }
|
||
: {};
|
||
const handleRefresh = React.useCallback(async () => {
|
||
await loadAchievements();
|
||
}, [loadAchievements]);
|
||
|
||
if (!token) {
|
||
return null;
|
||
}
|
||
|
||
const tabContent = (
|
||
<>
|
||
{activeTab === 'personal' && hasPersonal && data?.personal && (
|
||
<div className="space-y-5">
|
||
<Card>
|
||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<CardTitle className="text-lg font-semibold">
|
||
{t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t('achievements.personal.stats', {
|
||
photos: formatNumber(data.personal.photos),
|
||
tasks: formatNumber(data.personal.tasks),
|
||
likes: formatNumber(data.personal.likes),
|
||
})}
|
||
</CardDescription>
|
||
</div>
|
||
<PersonalActions token={token} t={t} tasksEnabled={tasksEnabled} />
|
||
</CardHeader>
|
||
</Card>
|
||
|
||
<BadgesGrid badges={data.personal.badges} t={t} />
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'event' && data && (
|
||
<div className="space-y-5">
|
||
<Highlights
|
||
topPhoto={data.highlights.topPhoto}
|
||
trendingEmotion={data.highlights.trendingEmotion}
|
||
t={t}
|
||
formatRelativeTime={formatRelative}
|
||
locale={locale}
|
||
formatNumber={formatNumber}
|
||
/>
|
||
<Timeline points={data.highlights.timeline} t={t} formatNumber={formatNumber} />
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Leaderboard
|
||
title={t('achievements.leaderboard.uploadsTitle')}
|
||
description={t('achievements.leaderboard.description')}
|
||
icon={Users}
|
||
entries={data.leaderboards.uploads}
|
||
emptyCopy={t('achievements.leaderboard.uploadsEmpty')}
|
||
t={t}
|
||
formatNumber={formatNumber}
|
||
/>
|
||
<Leaderboard
|
||
title={t('achievements.leaderboard.likesTitle')}
|
||
description={t('achievements.leaderboard.description')}
|
||
icon={Trophy}
|
||
entries={data.leaderboards.likes}
|
||
emptyCopy={t('achievements.leaderboard.likesEmpty')}
|
||
t={t}
|
||
formatNumber={formatNumber}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'feed' && data && (
|
||
<Feed
|
||
feed={data.feed}
|
||
t={t}
|
||
formatRelativeTime={formatRelative}
|
||
locale={locale}
|
||
formatNumber={formatNumber}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<PullToRefresh
|
||
onRefresh={handleRefresh}
|
||
pullLabel={t('common.pullToRefresh')}
|
||
releaseLabel={t('common.releaseToRefresh')}
|
||
refreshingLabel={t('common.refreshing')}
|
||
>
|
||
<motion.div className="space-y-6 pb-24" {...containerMotion}>
|
||
<motion.div className="space-y-2" {...fadeUpMotion}>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||
<Award className="h-5 w-5" aria-hidden />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-semibold text-foreground">{t('achievements.page.title')}</h1>
|
||
<p className="text-sm text-muted-foreground">{t('achievements.page.subtitle')}</p>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
|
||
{loading && (
|
||
<motion.div className="space-y-4" {...fadeUpMotion}>
|
||
<Skeleton className="h-24 w-full" />
|
||
<Skeleton className="h-32 w-full" />
|
||
<Skeleton className="h-48 w-full" />
|
||
</motion.div>
|
||
)}
|
||
|
||
{!loading && error && (
|
||
<motion.div {...fadeUpMotion}>
|
||
<Alert variant="destructive">
|
||
<AlertDescription className="flex items-center justify-between gap-3">
|
||
<span>{error === GENERIC_ERROR ? t('achievements.page.loadError') : error}</span>
|
||
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
|
||
{t('achievements.page.retry')}
|
||
</Button>
|
||
</AlertDescription>
|
||
</Alert>
|
||
</motion.div>
|
||
)}
|
||
|
||
{!loading && !error && data && (
|
||
<>
|
||
<motion.div className="flex flex-wrap items-center gap-2" {...fadeUpMotion}>
|
||
<Button
|
||
variant={activeTab === 'personal' ? 'default' : 'outline'}
|
||
onClick={() => setActiveTab('personal')}
|
||
disabled={!hasPersonal}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Sparkles className="h-4 w-4" aria-hidden />
|
||
{t('achievements.page.buttons.personal')}
|
||
</Button>
|
||
<Button
|
||
variant={activeTab === 'event' ? 'default' : 'outline'}
|
||
onClick={() => setActiveTab('event')}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Users className="h-4 w-4" aria-hidden />
|
||
{t('achievements.page.buttons.event')}
|
||
</Button>
|
||
<Button
|
||
variant={activeTab === 'feed' ? 'default' : 'outline'}
|
||
onClick={() => setActiveTab('feed')}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<BarChart2 className="h-4 w-4" aria-hidden />
|
||
{t('achievements.page.buttons.feed')}
|
||
</Button>
|
||
</motion.div>
|
||
|
||
<motion.div {...fadeUpMotion}>
|
||
<Separator />
|
||
</motion.div>
|
||
|
||
{motionEnabled ? (
|
||
<AnimatePresence mode="wait" initial={false}>
|
||
<motion.div key={activeTab} {...tabMotion}>
|
||
{tabContent}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
) : (
|
||
<motion.div {...fadeScaleMotion}>{tabContent}</motion.div>
|
||
)}
|
||
</>
|
||
)}
|
||
</motion.div>
|
||
</PullToRefresh>
|
||
);
|
||
}
|