feat(frontend): add react spa with wishlist flows and public profile
This commit is contained in:
48
apps/frontend/src/pages/ArchivePage.tsx
Normal file
48
apps/frontend/src/pages/ArchivePage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { WishCard } from '@/components/WishCard/WishCard';
|
||||
import {
|
||||
useDeleteWish,
|
||||
useRestoreWish,
|
||||
useWishes,
|
||||
} from '@/features/wishes/wishes.hooks';
|
||||
import { Archive } from 'lucide-react';
|
||||
|
||||
export function ArchivePage() {
|
||||
const { data, isLoading } = useWishes('archived');
|
||||
const restore = useRestoreWish();
|
||||
const remove = useDeleteWish();
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<h1 className="font-display text-3xl">Archive</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Wishes you put aside. Only you see this. Restore them to your active list any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading && <div className="text-muted">Loading...</div>}
|
||||
|
||||
{!isLoading && data && data.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||
<Archive className="h-10 w-10 text-muted" />
|
||||
<h2 className="text-xl font-semibold">Archive is empty</h2>
|
||||
<p className="text-sm text-muted">Archived wishes will show up here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((wish) => (
|
||||
<WishCard
|
||||
key={wish.id}
|
||||
wish={wish}
|
||||
view="owner"
|
||||
onRestore={() => restore.mutate(wish.id)}
|
||||
onDelete={() => remove.mutate(wish.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
apps/frontend/src/pages/CompletedPage.tsx
Normal file
50
apps/frontend/src/pages/CompletedPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { WishCard } from '@/components/WishCard/WishCard';
|
||||
import {
|
||||
useDeleteWish,
|
||||
useDuplicateWish,
|
||||
useWishes,
|
||||
} from '@/features/wishes/wishes.hooks';
|
||||
|
||||
export function CompletedPage() {
|
||||
const { data, isLoading } = useWishes('completed');
|
||||
const duplicate = useDuplicateWish();
|
||||
const remove = useDeleteWish();
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<h1 className="font-display text-3xl">Fulfilled</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Wishes you've received. You can create a new wish based on any of them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading && <div className="text-muted">Loading...</div>}
|
||||
|
||||
{!isLoading && data && data.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||
<CheckCircle2 className="h-10 w-10 text-muted" />
|
||||
<h2 className="text-xl font-semibold">Nothing fulfilled yet</h2>
|
||||
<p className="text-sm text-muted">
|
||||
When a wish comes true, mark it as fulfilled and it lands here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((wish) => (
|
||||
<WishCard
|
||||
key={wish.id}
|
||||
wish={wish}
|
||||
view="owner"
|
||||
onDuplicate={() => duplicate.mutate(wish.id)}
|
||||
onDelete={() => remove.mutate(wish.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/frontend/src/pages/DashboardPage.tsx
Normal file
102
apps/frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Sparkles } from 'lucide-react';
|
||||
import type { Wish } from '@family-wishlist/shared';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { WishCard } from '@/components/WishCard/WishCard';
|
||||
import { WishForm } from '@/components/WishForm/WishForm';
|
||||
import {
|
||||
useArchiveWish,
|
||||
useCompleteWish,
|
||||
useDeleteWish,
|
||||
useWishes,
|
||||
} from '@/features/wishes/wishes.hooks';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { data, isLoading } = useWishes('active');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<Wish | null>(null);
|
||||
const archive = useArchiveWish();
|
||||
const complete = useCompleteWish();
|
||||
const remove = useDeleteWish();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl">Your wishlist</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Add things you dream of. Share your public page at{' '}
|
||||
{user && (
|
||||
<Link
|
||||
to={`/u/${user.slug}`}
|
||||
className="font-medium text-primary hover:text-primary-600"
|
||||
>
|
||||
/u/{user.slug}
|
||||
</Link>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={() => setCreating(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add wish
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="wish-card animate-pulse"
|
||||
style={{ minHeight: 320, background: 'rgb(var(--color-surface-muted))' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">No wishes yet</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Start by adding something you'd love to receive.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreating(true)}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Add your first wish
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((wish) => (
|
||||
<WishCard
|
||||
key={wish.id}
|
||||
wish={wish}
|
||||
view="owner"
|
||||
onEdit={() => setEditing(wish)}
|
||||
onArchive={() => archive.mutate(wish.id)}
|
||||
onComplete={() => complete.mutate(wish.id)}
|
||||
onDelete={() => remove.mutate(wish.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WishForm open={creating} mode="create" onClose={() => setCreating(false)} />
|
||||
<WishForm
|
||||
open={editing !== null}
|
||||
mode="edit"
|
||||
initial={editing ?? undefined}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
apps/frontend/src/pages/LoginPage.tsx
Normal file
98
apps/frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Gift, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { loginSchema, type LoginInput } from '@family-wishlist/shared';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import { Footer } from '@/components/Layout/Footer';
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { username: '', password: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate(from, { replace: true });
|
||||
}, [user, from, navigate]);
|
||||
|
||||
if (user) return <Navigate to={from} replace />;
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) toast.error(err.message);
|
||||
else toast.error('Login failed');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<div className="container-page flex flex-1 items-center justify-center py-12">
|
||||
<div className="w-full max-w-md animate-fade-in-up">
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
||||
<Gift className="h-5 w-5" />
|
||||
</span>
|
||||
<h1 className="font-display text-3xl">Family Wishlist</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-card sm:p-8">
|
||||
<h2 className="mb-1 text-xl font-semibold">Welcome back</h2>
|
||||
<p className="mb-6 text-sm text-muted">
|
||||
Sign in to manage your wishlist. Credentials are set up via the server environment.
|
||||
</p>
|
||||
<form className="grid gap-4" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
{...register('username')}
|
||||
/>
|
||||
{errors.username && (
|
||||
<span className="field__error">{errors.username.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<span className="field__error">{errors.password.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/frontend/src/pages/NotFoundPage.tsx
Normal file
16
apps/frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="max-w-md rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
||||
<h1 className="font-display text-4xl">404</h1>
|
||||
<p className="mt-2 text-muted">We couldn't find that page.</p>
|
||||
<Link to="/" className="mt-4 inline-block">
|
||||
<Button variant="secondary">Back to home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
apps/frontend/src/pages/ProfileSettingsPage.tsx
Normal file
121
apps/frontend/src/pages/ProfileSettingsPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
updateProfileSchema,
|
||||
type UpdateProfileInput,
|
||||
type Profile,
|
||||
} from '@family-wishlist/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
|
||||
export function ProfileSettingsPage() {
|
||||
const refresh = useAuthStore((s) => s.refresh);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get<Profile>('/api/profile'),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm<UpdateProfileInput>({
|
||||
resolver: zodResolver(updateProfileSchema),
|
||||
defaultValues: { slug: '', displayName: '', bio: '', avatarUrl: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({
|
||||
slug: data.slug,
|
||||
displayName: data.displayName,
|
||||
bio: data.bio ?? '',
|
||||
avatarUrl: data.avatarUrl ?? '',
|
||||
});
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (values: UpdateProfileInput) =>
|
||||
api.patch<Profile, UpdateProfileInput>('/api/profile', values),
|
||||
onSuccess: (p) => {
|
||||
toast.success('Profile saved');
|
||||
void queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
void refresh();
|
||||
reset({
|
||||
slug: p.slug,
|
||||
displayName: p.displayName,
|
||||
bio: p.bio ?? '',
|
||||
avatarUrl: p.avatarUrl ?? '',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) toast.error(err.message);
|
||||
else toast.error('Save failed');
|
||||
},
|
||||
});
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
const payload: UpdateProfileInput = {
|
||||
...values,
|
||||
bio: values.bio ? values.bio : null,
|
||||
avatarUrl: values.avatarUrl ? values.avatarUrl : null,
|
||||
};
|
||||
update.mutate(payload);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<section>
|
||||
<h1 className="font-display text-3xl">Profile</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Your public page lives at <code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted">Loading...</div>
|
||||
) : (
|
||||
<form className="grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<Label htmlFor="slug">Slug (public URL)</Label>
|
||||
<Input id="slug" {...register('slug')} />
|
||||
{errors.slug && <span className="field__error">{errors.slug.message}</span>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="displayName">Display name</Label>
|
||||
<Input id="displayName" {...register('displayName')} />
|
||||
{errors.displayName && (
|
||||
<span className="field__error">{errors.displayName.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea id="bio" rows={3} {...register('bio')} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="avatarUrl">Avatar URL</Label>
|
||||
<Input id="avatarUrl" type="url" {...register('avatarUrl')} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
apps/frontend/src/pages/PublicProfilePage.tsx
Normal file
110
apps/frontend/src/pages/PublicProfilePage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
PublicProfile,
|
||||
Wish,
|
||||
} from '@family-wishlist/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { WishCard } from '@/components/WishCard/WishCard';
|
||||
import { Footer } from '@/components/Layout/Footer';
|
||||
import { Gift } from 'lucide-react';
|
||||
|
||||
export function PublicProfilePage() {
|
||||
const { slug = '' } = useParams<{ slug: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const profile = useQuery({
|
||||
queryKey: ['public-profile', slug],
|
||||
queryFn: () => api.get<PublicProfile>(`/api/public/${encodeURIComponent(slug)}`),
|
||||
retry: false,
|
||||
enabled: slug.length > 0,
|
||||
});
|
||||
|
||||
const wishes = useQuery({
|
||||
queryKey: ['public-wishes', slug],
|
||||
queryFn: () => api.get<Wish[]>(`/api/public/${encodeURIComponent(slug)}/wishes`),
|
||||
enabled: slug.length > 0 && profile.isSuccess,
|
||||
});
|
||||
|
||||
const markSeen = useMutation({
|
||||
mutationFn: (wishIds: string[]) =>
|
||||
api.post(`/api/public/${encodeURIComponent(slug)}/views`, { wishIds }),
|
||||
});
|
||||
|
||||
const marked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!wishes.data || marked.current) return;
|
||||
const newIds = wishes.data.filter((w) => w.isNewForGuest).map((w) => w.id);
|
||||
if (newIds.length === 0) {
|
||||
marked.current = true;
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(() => {
|
||||
markSeen.mutate(newIds, {
|
||||
onSuccess: () => {
|
||||
marked.current = true;
|
||||
void queryClient.invalidateQueries({ queryKey: ['public-wishes', slug] });
|
||||
},
|
||||
});
|
||||
}, 1500);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [wishes.data, markSeen, queryClient, slug]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<main className="container-page flex-1 py-10">
|
||||
{profile.isLoading && <div className="text-muted">Loading...</div>}
|
||||
|
||||
{profile.isError && (
|
||||
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
||||
<h1 className="font-display text-2xl">Profile not found</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Check the link and try again. Slugs are case-sensitive.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.data && (
|
||||
<>
|
||||
<section className="mb-10 flex flex-col items-center gap-3 text-center">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-card">
|
||||
{profile.data.avatarUrl ? (
|
||||
<img
|
||||
src={profile.data.avatarUrl}
|
||||
alt=""
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Gift className="h-6 w-6" />
|
||||
)}
|
||||
</span>
|
||||
<h1 className="font-display text-4xl">{profile.data.displayName}'s wishlist</h1>
|
||||
{profile.data.bio && (
|
||||
<p className="max-w-xl text-muted">{profile.data.bio}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{wishes.isLoading && <div className="text-muted">Loading wishes...</div>}
|
||||
|
||||
{wishes.data && wishes.data.length === 0 && (
|
||||
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||
<h2 className="text-xl font-semibold">No wishes yet</h2>
|
||||
<p className="mt-1 text-sm text-muted">Check back later!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wishes.data && wishes.data.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{wishes.data.map((wish) => (
|
||||
<WishCard key={wish.id} wish={wish} view="guest" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/frontend/src/pages/TrashPage.tsx
Normal file
54
apps/frontend/src/pages/TrashPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
|
||||
import { WishCard } from '@/components/WishCard/WishCard';
|
||||
import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks';
|
||||
import { daysLeftUntil } from '@/lib/format';
|
||||
|
||||
export function TrashPage() {
|
||||
const { data, isLoading } = useWishes('deleted');
|
||||
const restore = useRestoreWish();
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<h1 className="font-display text-3xl">Trash</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Deleted wishes are kept for {TRASH_RETENTION_DAYS} days, then permanently removed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading && <div className="text-muted">Loading...</div>}
|
||||
|
||||
{!isLoading && data && data.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||
<Trash2 className="h-10 w-10 text-muted" />
|
||||
<h2 className="text-xl font-semibold">Trash is empty</h2>
|
||||
<p className="text-sm text-muted">Deleted wishes will appear here for 30 days.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((wish) => {
|
||||
const left = wish.deletedAt
|
||||
? daysLeftUntil(wish.deletedAt, TRASH_RETENTION_DAYS)
|
||||
: TRASH_RETENTION_DAYS;
|
||||
return (
|
||||
<WishCard
|
||||
key={wish.id}
|
||||
wish={wish}
|
||||
view="owner"
|
||||
onRestore={() => restore.mutate(wish.id)}
|
||||
footer={
|
||||
<p className="mt-2 text-xs font-medium text-warning">
|
||||
Auto-removes in {left} day{left === 1 ? '' : 's'}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user