+

{
+ (e.currentTarget as HTMLImageElement).src = '/default-gift.svg';
+ }}
+ />
+
+
+
+ {actions.length > 0 && (
+
+
+
+ {menuOpen && (
+ <>
+
setMenuOpen(false)}
+ aria-hidden
+ />
+
+ {actions.map((a) => (
+
+ ))}
+
+ >
+ )}
+
+
+ )}
+
+
+
{wish.title}
+
+ {wish.comment &&
{wish.comment}
}
+ {footer}
+
+
+ );
+}
+
+export const WishCard = memo(WishCardInner);
diff --git a/apps/frontend/src/components/WishForm/WishForm.tsx b/apps/frontend/src/components/WishForm/WishForm.tsx
new file mode 100644
index 0000000..31ad2a4
--- /dev/null
+++ b/apps/frontend/src/components/WishForm/WishForm.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useRef, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { createWishSchema, type CreateWishInput, type Wish } from '@family-wishlist/shared';
+import { Button } from '../ui/Button';
+import { Input } from '../ui/Input';
+import { Textarea } from '../ui/Textarea';
+import { Label } from '../ui/Label';
+import { Modal } from '../ui/Modal';
+import { ImageIcon, Loader2, RefreshCcw, Trash2, Upload } from 'lucide-react';
+import {
+ useCreateWish,
+ useRefreshOg,
+ useResetWishImage,
+ useUpdateWish,
+ useUploadWishImage,
+} from '@/features/wishes/wishes.hooks';
+
+interface Props {
+ open: boolean;
+ mode: 'create' | 'edit';
+ initial?: Wish;
+ onClose: () => void;
+}
+
+export function WishForm({ open, mode, initial, onClose }: Props) {
+ const create = useCreateWish();
+ const update = useUpdateWish();
+ const upload = useUploadWishImage();
+ const refreshOg = useRefreshOg();
+ const resetImage = useResetWishImage();
+
+ const fileInputRef = useRef
(null);
+ const [pendingId, setPendingId] = useState(null);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(createWishSchema),
+ defaultValues: {
+ title: initial?.title ?? '',
+ price: initial?.price ?? '',
+ currency: initial?.currency ?? 'RUB',
+ url: initial?.url ?? '',
+ comment: initial?.comment ?? '',
+ },
+ });
+
+ useEffect(() => {
+ if (!open) return;
+ reset({
+ title: initial?.title ?? '',
+ price: initial?.price ?? '',
+ currency: initial?.currency ?? 'RUB',
+ url: initial?.url ?? '',
+ comment: initial?.comment ?? '',
+ });
+ setPendingId(initial?.id ?? null);
+ }, [open, initial, reset]);
+
+ const submit = handleSubmit(async (values) => {
+ if (mode === 'create') {
+ const created = await create.mutateAsync(values);
+ setPendingId(created.id);
+ } else if (initial) {
+ await update.mutateAsync({ id: initial.id, input: values });
+ }
+ onClose();
+ });
+
+ const activeId = pendingId ?? initial?.id ?? null;
+ const canEditImage = activeId != null;
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ {canEditImage && activeId && (
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/components/ui/Button.tsx b/apps/frontend/src/components/ui/Button.tsx
new file mode 100644
index 0000000..fdfab9b
--- /dev/null
+++ b/apps/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,42 @@
+import { forwardRef, type ButtonHTMLAttributes } from 'react';
+import { cn } from '@/lib/cn';
+
+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
+type Size = 'sm' | 'md' | 'lg' | 'icon';
+
+const base =
+ 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all duration-150 focus:outline-none focus-visible:shadow-focus disabled:cursor-not-allowed disabled:opacity-50';
+
+const variants: Record = {
+ primary: 'bg-primary text-primary-foreground hover:bg-primary-600 shadow-card',
+ secondary: 'bg-surface text-ink shadow-card hover:bg-surface-muted',
+ ghost: 'text-ink hover:bg-ink/5',
+ outline: 'border border-border bg-surface text-ink hover:bg-surface-muted',
+ danger: 'bg-danger text-white hover:brightness-95 shadow-card',
+};
+
+const sizes: Record = {
+ sm: 'h-8 px-3 text-sm',
+ md: 'h-10 px-4 text-sm',
+ lg: 'h-12 px-6 text-base',
+ icon: 'h-9 w-9',
+};
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: Variant;
+ size?: Size;
+}
+
+export const Button = forwardRef(function Button(
+ { className, variant = 'primary', size = 'md', type = 'button', ...rest },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/apps/frontend/src/components/ui/Input.tsx b/apps/frontend/src/components/ui/Input.tsx
new file mode 100644
index 0000000..8099ad3
--- /dev/null
+++ b/apps/frontend/src/components/ui/Input.tsx
@@ -0,0 +1,8 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { cn } from '@/lib/cn';
+
+export const Input = forwardRef>(
+ function Input({ className, ...rest }, ref) {
+ return ;
+ },
+);
diff --git a/apps/frontend/src/components/ui/Label.tsx b/apps/frontend/src/components/ui/Label.tsx
new file mode 100644
index 0000000..b5f4eb2
--- /dev/null
+++ b/apps/frontend/src/components/ui/Label.tsx
@@ -0,0 +1,6 @@
+import type { LabelHTMLAttributes } from 'react';
+import { cn } from '@/lib/cn';
+
+export function Label({ className, ...rest }: LabelHTMLAttributes) {
+ return ;
+}
diff --git a/apps/frontend/src/components/ui/Modal.tsx b/apps/frontend/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..f1a73c7
--- /dev/null
+++ b/apps/frontend/src/components/ui/Modal.tsx
@@ -0,0 +1,78 @@
+import { useEffect, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { X } from 'lucide-react';
+import { cn } from '@/lib/cn';
+import { Button } from './Button';
+
+interface ModalProps {
+ open: boolean;
+ onClose: () => void;
+ title: ReactNode;
+ description?: ReactNode;
+ children: ReactNode;
+ footer?: ReactNode;
+ size?: 'md' | 'lg';
+}
+
+export function Modal({
+ open,
+ onClose,
+ title,
+ description,
+ children,
+ footer,
+ size = 'md',
+}: ModalProps) {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKey);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => {
+ window.removeEventListener('keydown', onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return createPortal(
+
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+
+
{children}
+ {footer && (
+
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/apps/frontend/src/components/ui/Textarea.tsx b/apps/frontend/src/components/ui/Textarea.tsx
new file mode 100644
index 0000000..e355020
--- /dev/null
+++ b/apps/frontend/src/components/ui/Textarea.tsx
@@ -0,0 +1,16 @@
+import { forwardRef, type TextareaHTMLAttributes } from 'react';
+import { cn } from '@/lib/cn';
+
+export const Textarea = forwardRef<
+ HTMLTextAreaElement,
+ TextareaHTMLAttributes
+>(function Textarea({ className, rows = 3, ...rest }, ref) {
+ return (
+
+ );
+});
diff --git a/apps/frontend/src/features/auth/authStore.ts b/apps/frontend/src/features/auth/authStore.ts
new file mode 100644
index 0000000..abb5410
--- /dev/null
+++ b/apps/frontend/src/features/auth/authStore.ts
@@ -0,0 +1,49 @@
+import { create } from 'zustand';
+import type { AuthUser } from '@family-wishlist/shared';
+import { api, ApiError } from '@/lib/api';
+
+interface AuthState {
+ user: AuthUser | null;
+ status: 'idle' | 'loading' | 'ready';
+ init: () => Promise;
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+ refresh: () => Promise;
+}
+
+export const useAuthStore = create((set) => ({
+ user: null,
+ status: 'idle',
+ init: async () => {
+ set({ status: 'loading' });
+ try {
+ const user = await api.get('/api/auth/me');
+ set({ user, status: 'ready' });
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ set({ user: null, status: 'ready' });
+ return;
+ }
+ set({ user: null, status: 'ready' });
+ }
+ },
+ login: async (username, password) => {
+ const user = await api.post('/api/auth/login', { username, password });
+ set({ user });
+ },
+ logout: async () => {
+ try {
+ await api.post('/api/auth/logout');
+ } finally {
+ set({ user: null });
+ }
+ },
+ refresh: async () => {
+ try {
+ const user = await api.get('/api/auth/me');
+ set({ user });
+ } catch {
+ set({ user: null });
+ }
+ },
+}));
diff --git a/apps/frontend/src/features/wishes/wishes.api.ts b/apps/frontend/src/features/wishes/wishes.api.ts
new file mode 100644
index 0000000..985ab1c
--- /dev/null
+++ b/apps/frontend/src/features/wishes/wishes.api.ts
@@ -0,0 +1,41 @@
+import type {
+ CreateWishInput,
+ UpdateWishInput,
+ Wish,
+ WishStatus,
+} from '@family-wishlist/shared';
+import { api } from '@/lib/api';
+
+export type WishWithOwnerBadge = Wish & { isNewForOwner?: boolean };
+
+export type OwnerStatus = 'active' | 'archived' | 'completed' | 'deleted';
+
+export const wishesApi = {
+ list: (status: OwnerStatus) =>
+ api.get(`/api/wishes?status=${status}`),
+
+ get: (id: string) => api.get(`/api/wishes/${id}`),
+
+ create: (input: CreateWishInput) => api.post('/api/wishes', input),
+
+ update: (id: string, input: UpdateWishInput) =>
+ api.patch(`/api/wishes/${id}`, input),
+
+ remove: (id: string) => api.delete(`/api/wishes/${id}`),
+ archive: (id: string) => api.post(`/api/wishes/${id}/archive`),
+ complete: (id: string) => api.post(`/api/wishes/${id}/complete`),
+ restore: (id: string) => api.post(`/api/wishes/${id}/restore`),
+ duplicate: (id: string) => api.post(`/api/wishes/${id}/duplicate`),
+
+ uploadImage: (id: string, file: File) => {
+ const fd = new FormData();
+ fd.append('file', file, file.name);
+ return api.upload(`/api/wishes/${id}/image`, fd);
+ },
+ refreshOg: (id: string) => api.post(`/api/wishes/${id}/image/refresh-og`),
+ deleteImage: (id: string) => api.delete(`/api/wishes/${id}/image`),
+};
+
+export function statusToQuery(s: WishStatus): OwnerStatus {
+ return s.toLowerCase() as OwnerStatus;
+}
diff --git a/apps/frontend/src/features/wishes/wishes.hooks.ts b/apps/frontend/src/features/wishes/wishes.hooks.ts
new file mode 100644
index 0000000..65528e1
--- /dev/null
+++ b/apps/frontend/src/features/wishes/wishes.hooks.ts
@@ -0,0 +1,145 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared';
+import { toast } from 'sonner';
+import { wishesApi, type OwnerStatus } from './wishes.api';
+import { ApiError } from '@/lib/api';
+
+const LIST_KEY = (status: OwnerStatus) => ['wishes', status] as const;
+
+function invalidateAll(client: ReturnType): void {
+ void client.invalidateQueries({ queryKey: ['wishes'] });
+}
+
+function toastError(err: unknown, fallback = 'Something went wrong'): void {
+ if (err instanceof ApiError) toast.error(err.message);
+ else toast.error(fallback);
+}
+
+export function useWishes(status: OwnerStatus) {
+ return useQuery({
+ queryKey: LIST_KEY(status),
+ queryFn: () => wishesApi.list(status),
+ staleTime: 10_000,
+ });
+}
+
+export function useCreateWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (input: CreateWishInput) => wishesApi.create(input),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Wish added');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useUpdateWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { id: string; input: UpdateWishInput }) =>
+ wishesApi.update(vars.id, vars.input),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Saved');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useArchiveWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.archive(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Moved to archive');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useCompleteWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.complete(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Marked as fulfilled');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useDeleteWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.remove(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Moved to trash (30 days to restore)');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useRestoreWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.restore(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Restored');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useDuplicateWish() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.duplicate(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('New wish created from the fulfilled one');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useUploadWishImage() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { id: string; file: File }) => wishesApi.uploadImage(vars.id, vars.file),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Image updated');
+ },
+ onError: (err) => toastError(err),
+ });
+}
+
+export function useRefreshOg() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.refreshOg(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Image refreshed from link');
+ },
+ onError: (err) => toastError(err, 'Could not fetch image from link'),
+ });
+}
+
+export function useResetWishImage() {
+ const client = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => wishesApi.deleteImage(id),
+ onSuccess: () => {
+ invalidateAll(client);
+ toast.success('Reset to default image');
+ },
+ onError: (err) => toastError(err),
+ });
+}
diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts
new file mode 100644
index 0000000..52044ae
--- /dev/null
+++ b/apps/frontend/src/lib/api.ts
@@ -0,0 +1,70 @@
+export interface ApiErrorShape {
+ error: string;
+ message: string;
+ details?: unknown;
+}
+
+export class ApiError extends Error {
+ readonly status: number;
+ readonly code: string;
+ readonly details?: unknown;
+ constructor(status: number, body: ApiErrorShape) {
+ super(body.message);
+ this.status = status;
+ this.code = body.error;
+ this.details = body.details;
+ }
+}
+
+interface RequestOptions {
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
+ body?: TBody;
+ signal?: AbortSignal;
+ formData?: FormData;
+}
+
+async function request(
+ path: string,
+ options: RequestOptions = {},
+): Promise {
+ const headers: Record = {};
+ let body: BodyInit | undefined;
+
+ if (options.formData) {
+ body = options.formData;
+ } else if (options.body !== undefined) {
+ headers['content-type'] = 'application/json';
+ body = JSON.stringify(options.body);
+ }
+
+ const response = await fetch(path, {
+ method: options.method ?? 'GET',
+ headers,
+ body,
+ credentials: 'include',
+ signal: options.signal,
+ });
+
+ const isJson = response.headers.get('content-type')?.includes('application/json');
+ const payload = isJson ? await response.json().catch(() => null) : null;
+
+ if (!response.ok) {
+ const errBody: ApiErrorShape =
+ payload && typeof payload === 'object' && 'error' in payload
+ ? (payload as ApiErrorShape)
+ : { error: 'HTTP', message: response.statusText || 'Request failed' };
+ throw new ApiError(response.status, errBody);
+ }
+
+ return payload as TResponse;
+}
+
+export const api = {
+ get: (path: string, signal?: AbortSignal) => request(path, { signal }),
+ post: (path: string, body?: B) => request(path, { method: 'POST', body }),
+ patch: (path: string, body?: B) =>
+ request(path, { method: 'PATCH', body }),
+ delete: (path: string) => request(path, { method: 'DELETE' }),
+ upload: (path: string, formData: FormData) =>
+ request(path, { method: 'POST', formData }),
+};
diff --git a/apps/frontend/src/lib/cn.ts b/apps/frontend/src/lib/cn.ts
new file mode 100644
index 0000000..2509dbc
--- /dev/null
+++ b/apps/frontend/src/lib/cn.ts
@@ -0,0 +1,6 @@
+import clsx, { type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/frontend/src/lib/format.ts b/apps/frontend/src/lib/format.ts
new file mode 100644
index 0000000..c4975ec
--- /dev/null
+++ b/apps/frontend/src/lib/format.ts
@@ -0,0 +1,25 @@
+export function formatPrice(price: string | null | undefined, currency: string): string | null {
+ if (!price) return null;
+ const n = Number(price);
+ if (Number.isNaN(n)) return `${price} ${currency}`;
+ try {
+ return new Intl.NumberFormat(undefined, {
+ style: 'currency',
+ currency,
+ maximumFractionDigits: 2,
+ }).format(n);
+ } catch {
+ return `${n.toLocaleString()} ${currency}`;
+ }
+}
+
+export function formatDate(iso: string | Date): string {
+ const d = typeof iso === 'string' ? new Date(iso) : iso;
+ return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
+}
+
+export function daysLeftUntil(iso: string | Date, retentionDays: number): number {
+ const d = typeof iso === 'string' ? new Date(iso) : iso;
+ const expires = d.getTime() + retentionDays * 24 * 60 * 60 * 1000;
+ return Math.max(0, Math.ceil((expires - Date.now()) / (24 * 60 * 60 * 1000)));
+}
diff --git a/apps/frontend/src/lib/version.ts b/apps/frontend/src/lib/version.ts
new file mode 100644
index 0000000..5c8a689
--- /dev/null
+++ b/apps/frontend/src/lib/version.ts
@@ -0,0 +1,4 @@
+declare const __FRONTEND_VERSION__: string;
+
+export const FRONTEND_VERSION: string =
+ typeof __FRONTEND_VERSION__ !== 'undefined' ? __FRONTEND_VERSION__ : '0.0.0';
diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx
new file mode 100644
index 0000000..5de30b3
--- /dev/null
+++ b/apps/frontend/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+import './styles/global.css';
+
+const el = document.getElementById('root');
+if (!el) throw new Error('#root not found');
+
+createRoot(el).render(
+
+
+ ,
+);
diff --git a/apps/frontend/src/pages/ArchivePage.tsx b/apps/frontend/src/pages/ArchivePage.tsx
new file mode 100644
index 0000000..40e3e9b
--- /dev/null
+++ b/apps/frontend/src/pages/ArchivePage.tsx
@@ -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 (
+
+
+ Archive
+
+ Wishes you put aside. Only you see this. Restore them to your active list any time.
+
+
+
+ {isLoading &&
Loading...
}
+
+ {!isLoading && data && data.length === 0 && (
+
+
+
Archive is empty
+
Archived wishes will show up here.
+
+ )}
+
+ {data && data.length > 0 && (
+
+ {data.map((wish) => (
+ restore.mutate(wish.id)}
+ onDelete={() => remove.mutate(wish.id)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/pages/CompletedPage.tsx b/apps/frontend/src/pages/CompletedPage.tsx
new file mode 100644
index 0000000..e9850b3
--- /dev/null
+++ b/apps/frontend/src/pages/CompletedPage.tsx
@@ -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 (
+
+
+ Fulfilled
+
+ Wishes you've received. You can create a new wish based on any of them.
+
+
+
+ {isLoading &&
Loading...
}
+
+ {!isLoading && data && data.length === 0 && (
+
+
+
Nothing fulfilled yet
+
+ When a wish comes true, mark it as fulfilled and it lands here.
+
+
+ )}
+
+ {data && data.length > 0 && (
+
+ {data.map((wish) => (
+ duplicate.mutate(wish.id)}
+ onDelete={() => remove.mutate(wish.id)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/pages/DashboardPage.tsx b/apps/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..8d49439
--- /dev/null
+++ b/apps/frontend/src/pages/DashboardPage.tsx
@@ -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(null);
+ const archive = useArchiveWish();
+ const complete = useCompleteWish();
+ const remove = useDeleteWish();
+ const user = useAuthStore((s) => s.user);
+
+ return (
+
+
+
+
Your wishlist
+
+ Add things you dream of. Share your public page at{' '}
+ {user && (
+
+ /u/{user.slug}
+
+ )}
+ .
+
+
+
+
+
+ {isLoading && (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ )}
+
+ {!isLoading && data && data.length === 0 && (
+
+

+
+
No wishes yet
+
+ Start by adding something you'd love to receive.
+
+
+
+
+ )}
+
+ {!isLoading && data && data.length > 0 && (
+
+ {data.map((wish) => (
+ setEditing(wish)}
+ onArchive={() => archive.mutate(wish.id)}
+ onComplete={() => complete.mutate(wish.id)}
+ onDelete={() => remove.mutate(wish.id)}
+ />
+ ))}
+
+ )}
+
+
setCreating(false)} />
+ setEditing(null)}
+ />
+
+ );
+}
diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..28fb37d
--- /dev/null
+++ b/apps/frontend/src/pages/LoginPage.tsx
@@ -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({
+ resolver: zodResolver(loginSchema),
+ defaultValues: { username: '', password: '' },
+ });
+
+ useEffect(() => {
+ if (user) navigate(from, { replace: true });
+ }, [user, from, navigate]);
+
+ if (user) return ;
+
+ 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 (
+
+
+
+
+
+
+
+
Family Wishlist
+
+
+
+
Welcome back
+
+ Sign in to manage your wishlist. Credentials are set up via the server environment.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/pages/NotFoundPage.tsx b/apps/frontend/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..0181334
--- /dev/null
+++ b/apps/frontend/src/pages/NotFoundPage.tsx
@@ -0,0 +1,16 @@
+import { Link } from 'react-router-dom';
+import { Button } from '@/components/ui/Button';
+
+export function NotFoundPage() {
+ return (
+
+
+
404
+
We couldn't find that page.
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/pages/ProfileSettingsPage.tsx b/apps/frontend/src/pages/ProfileSettingsPage.tsx
new file mode 100644
index 0000000..2800fbe
--- /dev/null
+++ b/apps/frontend/src/pages/ProfileSettingsPage.tsx
@@ -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('/api/profile'),
+ });
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isSubmitting, isDirty },
+ } = useForm({
+ 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('/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 (
+
+
+ Profile
+
+ Your public page lives at /u/{data?.slug ?? '...'}.
+
+
+
+ {isLoading ? (
+
Loading...
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/pages/PublicProfilePage.tsx b/apps/frontend/src/pages/PublicProfilePage.tsx
new file mode 100644
index 0000000..f09c015
--- /dev/null
+++ b/apps/frontend/src/pages/PublicProfilePage.tsx
@@ -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(`/api/public/${encodeURIComponent(slug)}`),
+ retry: false,
+ enabled: slug.length > 0,
+ });
+
+ const wishes = useQuery({
+ queryKey: ['public-wishes', slug],
+ queryFn: () => api.get(`/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 (
+
+
+ {profile.isLoading && Loading...
}
+
+ {profile.isError && (
+
+
Profile not found
+
+ Check the link and try again. Slugs are case-sensitive.
+
+
+ )}
+
+ {profile.data && (
+ <>
+
+
+ {profile.data.avatarUrl ? (
+
+ ) : (
+
+ )}
+
+ {profile.data.displayName}'s wishlist
+ {profile.data.bio && (
+ {profile.data.bio}
+ )}
+
+
+ {wishes.isLoading && Loading wishes...
}
+
+ {wishes.data && wishes.data.length === 0 && (
+
+
No wishes yet
+
Check back later!
+
+ )}
+
+ {wishes.data && wishes.data.length > 0 && (
+
+ {wishes.data.map((wish) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/frontend/src/pages/TrashPage.tsx b/apps/frontend/src/pages/TrashPage.tsx
new file mode 100644
index 0000000..e19444b
--- /dev/null
+++ b/apps/frontend/src/pages/TrashPage.tsx
@@ -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 (
+
+
+ Trash
+
+ Deleted wishes are kept for {TRASH_RETENTION_DAYS} days, then permanently removed.
+
+
+
+ {isLoading &&
Loading...
}
+
+ {!isLoading && data && data.length === 0 && (
+
+
+
Trash is empty
+
Deleted wishes will appear here for 30 days.
+
+ )}
+
+ {data && data.length > 0 && (
+
+ {data.map((wish) => {
+ const left = wish.deletedAt
+ ? daysLeftUntil(wish.deletedAt, TRASH_RETENTION_DAYS)
+ : TRASH_RETENTION_DAYS;
+ return (
+
restore.mutate(wish.id)}
+ footer={
+
+ Auto-removes in {left} day{left === 1 ? '' : 's'}
+
+ }
+ />
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx
new file mode 100644
index 0000000..8cfd58c
--- /dev/null
+++ b/apps/frontend/src/routes.tsx
@@ -0,0 +1,31 @@
+import { createBrowserRouter } from 'react-router-dom';
+import { ProtectedRoute } from './components/Layout/ProtectedRoute';
+import { AppShell } from './components/Layout/AppShell';
+import { LoginPage } from './pages/LoginPage';
+import { DashboardPage } from './pages/DashboardPage';
+import { ArchivePage } from './pages/ArchivePage';
+import { CompletedPage } from './pages/CompletedPage';
+import { TrashPage } from './pages/TrashPage';
+import { ProfileSettingsPage } from './pages/ProfileSettingsPage';
+import { PublicProfilePage } from './pages/PublicProfilePage';
+import { NotFoundPage } from './pages/NotFoundPage';
+
+export const router = createBrowserRouter([
+ { path: '/login', element: },
+ { path: '/u/:slug', element: },
+ {
+ element: (
+
+
+
+ ),
+ children: [
+ { path: '/', element: },
+ { path: '/archive', element: },
+ { path: '/completed', element: },
+ { path: '/trash', element: },
+ { path: '/settings', element: },
+ ],
+ },
+ { path: '*', element: },
+]);
diff --git a/apps/frontend/src/styles/global.css b/apps/frontend/src/styles/global.css
new file mode 100644
index 0000000..3d8b699
--- /dev/null
+++ b/apps/frontend/src/styles/global.css
@@ -0,0 +1,102 @@
+@import './tokens.css';
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ html,
+ body,
+ #root {
+ height: 100%;
+ }
+ body {
+ @apply bg-background text-ink font-sans antialiased;
+ background-image:
+ radial-gradient(1200px 600px at 0% -10%, rgba(244, 192, 78, 0.18), transparent 60%),
+ radial-gradient(900px 500px at 100% 110%, rgba(226, 120, 150, 0.18), transparent 55%);
+ background-attachment: fixed;
+ }
+ h1,
+ h2,
+ h3 {
+ @apply font-display text-ink;
+ letter-spacing: -0.01em;
+ }
+ a {
+ @apply transition-colors;
+ }
+}
+
+@layer components {
+ /* BEM-friendly classes: kept parallel to Tailwind utilities where possible. */
+ .wish-card {
+ @apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200;
+ }
+ .wish-card:hover {
+ transform: translateY(-2px);
+ }
+ .wish-card--completed {
+ filter: saturate(0.45) brightness(0.98);
+ }
+ .wish-card--completed .wish-card__title {
+ text-decoration: line-through;
+ text-decoration-thickness: 2px;
+ text-decoration-color: rgb(var(--color-muted) / 0.6);
+ }
+
+ .wish-card__image-wrap {
+ @apply relative aspect-[4/3] bg-surface-muted overflow-hidden;
+ }
+ .wish-card__image {
+ @apply h-full w-full object-cover;
+ }
+ .wish-card__body {
+ @apply flex flex-col gap-2 p-4;
+ }
+ .wish-card__title {
+ @apply text-lg font-semibold leading-snug text-ink;
+ }
+ .wish-card__price {
+ @apply text-sm font-medium text-muted;
+ }
+ .wish-card__comment {
+ @apply text-sm text-muted line-clamp-2;
+ }
+
+ .wish-badge {
+ @apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide;
+ }
+ .wish-badge--new {
+ @apply bg-primary text-primary-foreground;
+ }
+ .wish-badge--completed {
+ @apply bg-ink/5 text-ink;
+ }
+ .wish-badge--archived {
+ @apply bg-warning/20 text-warning;
+ }
+ .wish-badge--deleted {
+ @apply bg-danger/15 text-danger;
+ }
+
+ .field {
+ @apply flex flex-col gap-1.5;
+ }
+ .field__label {
+ @apply text-sm font-medium text-ink;
+ }
+ .field__input,
+ .field__textarea,
+ .field__select {
+ @apply w-full rounded-md border border-border bg-surface px-3 py-2.5 text-ink outline-none transition-all duration-150 placeholder:text-muted focus:border-primary focus:shadow-[var(--shadow-focus)];
+ }
+ .field__error {
+ @apply text-xs text-danger;
+ }
+}
+
+@layer utilities {
+ .container-page {
+ @apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
+ }
+}
diff --git a/apps/frontend/src/styles/tokens.css b/apps/frontend/src/styles/tokens.css
new file mode 100644
index 0000000..cf0d781
--- /dev/null
+++ b/apps/frontend/src/styles/tokens.css
@@ -0,0 +1,35 @@
+:root {
+ /* Colors (RGB triplets so Tailwind works) */
+ --color-background: 255 247 240;
+ --color-surface: 255 255 255;
+ --color-surface-muted: 250 242 235;
+ --color-text: 42 33 53;
+ --color-muted: 142 128 153;
+ --color-border: 235 224 215;
+
+ --color-primary: 226 120 150;
+ --color-primary-50: 255 236 243;
+ --color-primary-600: 200 82 118;
+ --color-primary-foreground: 255 255 255;
+
+ --color-accent: 244 192 78;
+ --color-accent-foreground: 70 48 10;
+
+ --color-success: 97 186 129;
+ --color-warning: 240 168 104;
+ --color-danger: 226 107 107;
+
+ /* Radii */
+ --radius-sm: 8px;
+ --radius-md: 14px;
+ --radius-lg: 20px;
+ --radius-xl: 28px;
+
+ /* Shadows */
+ --shadow-card: 0 1px 2px rgba(42, 33, 53, 0.04), 0 10px 28px rgba(42, 33, 53, 0.07);
+ --shadow-pop: 0 8px 32px rgba(42, 33, 53, 0.18);
+ --shadow-focus: 0 0 0 3px rgba(226, 120, 150, 0.3);
+
+ /* Transitions */
+ --transition-base: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
diff --git a/apps/frontend/tailwind.config.ts b/apps/frontend/tailwind.config.ts
new file mode 100644
index 0000000..0684d87
--- /dev/null
+++ b/apps/frontend/tailwind.config.ts
@@ -0,0 +1,58 @@
+import type { Config } from 'tailwindcss';
+
+export default {
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ theme: {
+ extend: {
+ colors: {
+ // Mapped to CSS variables defined in src/styles/tokens.css so the design
+ // tokens stay the single source of truth.
+ background: 'rgb(var(--color-background) / )',
+ surface: 'rgb(var(--color-surface) / )',
+ 'surface-muted': 'rgb(var(--color-surface-muted) / )',
+ primary: {
+ DEFAULT: 'rgb(var(--color-primary) / )',
+ foreground: 'rgb(var(--color-primary-foreground) / )',
+ 600: 'rgb(var(--color-primary-600) / )',
+ 50: 'rgb(var(--color-primary-50) / )',
+ },
+ accent: {
+ DEFAULT: 'rgb(var(--color-accent) / )',
+ foreground: 'rgb(var(--color-accent-foreground) / )',
+ },
+ ink: 'rgb(var(--color-text) / )',
+ muted: 'rgb(var(--color-muted) / )',
+ border: 'rgb(var(--color-border) / )',
+ success: 'rgb(var(--color-success) / )',
+ warning: 'rgb(var(--color-warning) / )',
+ danger: 'rgb(var(--color-danger) / )',
+ },
+ fontFamily: {
+ sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
+ display: ['"Fraunces"', 'Georgia', 'serif'],
+ },
+ borderRadius: {
+ sm: 'var(--radius-sm)',
+ DEFAULT: 'var(--radius-md)',
+ md: 'var(--radius-md)',
+ lg: 'var(--radius-lg)',
+ xl: 'var(--radius-xl)',
+ },
+ boxShadow: {
+ card: 'var(--shadow-card)',
+ pop: 'var(--shadow-pop)',
+ focus: 'var(--shadow-focus)',
+ },
+ keyframes: {
+ fadeInUp: {
+ from: { opacity: '0', transform: 'translateY(8px)' },
+ to: { opacity: '1', transform: 'translateY(0)' },
+ },
+ },
+ animation: {
+ 'fade-in-up': 'fadeInUp 320ms ease-out both',
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/apps/frontend/tsconfig.app.json b/apps/frontend/tsconfig.app.json
new file mode 100644
index 0000000..01479d7
--- /dev/null
+++ b/apps/frontend/tsconfig.app.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "types": [],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*"]
+}
diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/apps/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/apps/frontend/tsconfig.node.json b/apps/frontend/tsconfig.node.json
new file mode 100644
index 0000000..f6af82b
--- /dev/null
+++ b/apps/frontend/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": ["node"],
+ "allowSyntheticDefaultImports": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts", "tailwind.config.ts", "postcss.config.js"]
+}
diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts
new file mode 100644
index 0000000..7b8cf10
--- /dev/null
+++ b/apps/frontend/vite.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig, loadEnv } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+import { readFileSync } from 'node:fs';
+
+const pkg = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8')) as {
+ version: string;
+};
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+ const apiTarget = env.VITE_API_TARGET ?? 'http://localhost:3000';
+
+ return {
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ define: {
+ __FRONTEND_VERSION__: JSON.stringify(pkg.version),
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': { target: apiTarget, changeOrigin: true },
+ '/uploads': { target: apiTarget, changeOrigin: true },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ },
+ };
+});