Compare commits
1 Commits
feat/backe
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f01611ed |
5
apps/frontend/.dockerignore
Normal file
5
apps/frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
20
apps/frontend/index.html
Normal file
20
apps/frontend/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Family Wishlist</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
apps/frontend/package.json
Normal file
39
apps/frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@family-wishlist/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "echo 'skip'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@family-wishlist/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.445.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
37
apps/frontend/public/default-gift.svg
Normal file
37
apps/frontend/public/default-gift.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 240" width="320" height="240" role="img" aria-label="Gift box">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFF2EA" />
|
||||
<stop offset="1" stop-color="#FFE2D1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="box" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#E27896" />
|
||||
<stop offset="1" stop-color="#C85276" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ribbon" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#F4C04E" />
|
||||
<stop offset="1" stop-color="#E0A21A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="320" height="240" fill="url(#bg)" />
|
||||
<g transform="translate(70 60)">
|
||||
<!-- box body -->
|
||||
<rect x="0" y="40" width="180" height="110" rx="10" fill="url(#box)" />
|
||||
<rect x="0" y="36" width="180" height="16" rx="8" fill="#F2A1B6" />
|
||||
<!-- vertical ribbon -->
|
||||
<rect x="80" y="36" width="20" height="114" fill="url(#ribbon)" />
|
||||
<!-- bow -->
|
||||
<g transform="translate(90 22)">
|
||||
<path d="M0 0 C-28 -24 -46 12 -20 14 L0 14 Z" fill="url(#ribbon)" />
|
||||
<path d="M0 0 C28 -24 46 12 20 14 L0 14 Z" fill="url(#ribbon)" />
|
||||
<circle cx="0" cy="10" r="6" fill="#F4C04E" stroke="#B7830D" stroke-width="1" />
|
||||
</g>
|
||||
<!-- sparkles -->
|
||||
<g fill="#F4C04E" opacity="0.9">
|
||||
<circle cx="-28" cy="18" r="3" />
|
||||
<circle cx="210" cy="48" r="2.5" />
|
||||
<circle cx="-18" cy="120" r="2" />
|
||||
<circle cx="206" cy="130" r="3" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
28
apps/frontend/public/empty-state.svg
Normal file
28
apps/frontend/public/empty-state.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 240" width="260" height="240" role="img" aria-label="Empty wishlist">
|
||||
<defs>
|
||||
<linearGradient id="e-bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFF2EA" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#FFE2D1" stop-opacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="20" y="30" width="220" height="190" rx="20" fill="url(#e-bg)" stroke="#EBD7C6" stroke-dasharray="4 6" />
|
||||
<g transform="translate(130 130)">
|
||||
<circle r="54" fill="#FFE2D1" />
|
||||
<circle r="54" fill="none" stroke="#F4C04E" stroke-opacity="0.5" stroke-width="2" />
|
||||
<g transform="translate(-32 -30)">
|
||||
<rect x="0" y="14" width="64" height="48" rx="6" fill="#E27896" />
|
||||
<rect x="0" y="10" width="64" height="10" rx="4" fill="#F2A1B6" />
|
||||
<rect x="28" y="10" width="8" height="52" fill="#F4C04E" />
|
||||
<g transform="translate(32 4)">
|
||||
<path d="M0 0 C-14 -12 -24 6 -10 8 L0 8 Z" fill="#F4C04E" />
|
||||
<path d="M0 0 C14 -12 24 6 10 8 L0 8 Z" fill="#F4C04E" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g fill="#F4C04E" opacity="0.85">
|
||||
<circle cx="40" cy="60" r="3" />
|
||||
<circle cx="222" cy="82" r="2.5" />
|
||||
<circle cx="52" cy="200" r="2" />
|
||||
<circle cx="214" cy="186" r="3" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
12
apps/frontend/public/favicon.svg
Normal file
12
apps/frontend/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect width="64" height="64" rx="14" fill="#E27896" />
|
||||
<g transform="translate(14 18)">
|
||||
<rect x="0" y="12" width="36" height="22" rx="3" fill="#FFF1F6" />
|
||||
<rect x="0" y="9" width="36" height="6" rx="2" fill="#F2A1B6" />
|
||||
<rect x="15" y="9" width="6" height="25" fill="#F4C04E" />
|
||||
<g transform="translate(18 5)">
|
||||
<path d="M0 0 C-8 -7 -14 3 -6 5 L0 5 Z" fill="#F4C04E" />
|
||||
<path d="M0 0 C8 -7 14 3 6 5 L0 5 Z" fill="#F4C04E" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
34
apps/frontend/src/App.tsx
Normal file
34
apps/frontend/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { router } from './routes';
|
||||
import { useAuthStore } from './features/auth/authStore';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AuthBoot({ children }: { children: React.ReactNode }) {
|
||||
const init = useAuthStore((s) => s.init);
|
||||
useEffect(() => {
|
||||
void init();
|
||||
}, [init]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBoot>
|
||||
<RouterProvider router={router} />
|
||||
</AuthBoot>
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
15
apps/frontend/src/components/Layout/AppShell.tsx
Normal file
15
apps/frontend/src/components/Layout/AppShell.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import { Footer } from './Footer';
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="container-page flex-1 py-6 sm:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/frontend/src/components/Layout/Footer.tsx
Normal file
32
apps/frontend/src/components/Layout/Footer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Gift } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { FRONTEND_VERSION } from '@/lib/version';
|
||||
|
||||
interface VersionInfo {
|
||||
backend: string;
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['version'],
|
||||
queryFn: () => api.get<VersionInfo>('/api/version'),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<footer className="container-page mt-10 py-6 text-xs text-muted">
|
||||
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="h-4 w-4" aria-hidden />
|
||||
<span className="font-display text-sm">Family Wishlist</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>frontend v{FRONTEND_VERSION}</span>
|
||||
<span className="opacity-50">·</span>
|
||||
<span>backend v{data?.backend ?? '...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
79
apps/frontend/src/components/Layout/Header.tsx
Normal file
79
apps/frontend/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { Archive, Gift, LogOut, Sparkles, Trash2, UserCog, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: 'Active', icon: Sparkles, end: true },
|
||||
{ to: '/completed', label: 'Fulfilled', icon: CheckCircle2 },
|
||||
{ to: '/archive', label: 'Archive', icon: Archive },
|
||||
{ to: '/trash', label: 'Trash', icon: Trash2 },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="container-page pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
||||
<Gift className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display text-lg leading-tight">Family Wishlist</div>
|
||||
<div className="text-xs text-muted">
|
||||
signed in as <span className="font-medium text-ink">{user.displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-1">
|
||||
{links.map((l) => (
|
||||
<NavLink
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
end={l.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5',
|
||||
)
|
||||
}
|
||||
>
|
||||
<l.icon className="h-4 w-4" />
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="Profile settings"
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
Profile
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void logout().then(() => navigate('/login'));
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/components/Layout/ProtectedRoute.tsx
Normal file
17
apps/frontend/src/components/Layout/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { user, status } = useAuthStore();
|
||||
const location = useLocation();
|
||||
if (status !== 'ready') {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-muted">Loading...</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
51
apps/frontend/src/components/WishBadges/WishBadges.tsx
Normal file
51
apps/frontend/src/components/WishBadges/WishBadges.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Check, Sparkles, Archive, Trash2 } from 'lucide-react';
|
||||
import type { Wish } from '@family-wishlist/shared';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
wish: Wish & { isNewForOwner?: boolean };
|
||||
view: 'owner' | 'guest';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WishBadges({ wish, view, className }: Props) {
|
||||
const badges: JSX.Element[] = [];
|
||||
|
||||
const isNew =
|
||||
view === 'owner' ? wish.isNewForOwner === true : wish.isNewForGuest === true;
|
||||
if (isNew && wish.status === 'ACTIVE') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--new" key="new">
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
new
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (wish.status === 'COMPLETED') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--completed" key="done">
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
fulfilled
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (wish.status === 'ARCHIVED' && view === 'owner') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--archived" key="arch">
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
archived
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (wish.status === 'DELETED' && view === 'owner') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--deleted" key="del">
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
trash
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (badges.length === 0) return null;
|
||||
return <div className={cn('flex flex-wrap gap-1.5', className)}>{badges}</div>;
|
||||
}
|
||||
191
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
191
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { memo, useState } from 'react';
|
||||
import type { Wish } from '@family-wishlist/shared';
|
||||
import {
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { WishBadges } from '../WishBadges/WishBadges';
|
||||
import { Button } from '../ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatPrice } from '@/lib/format';
|
||||
|
||||
export type WishCardView = 'owner' | 'guest';
|
||||
|
||||
export interface WishCardAction {
|
||||
key: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface WishCardProps {
|
||||
wish: Wish & { isNewForOwner?: boolean };
|
||||
view: WishCardView;
|
||||
onEdit?: () => void;
|
||||
onArchive?: () => void;
|
||||
onComplete?: () => void;
|
||||
onRestore?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete?: () => void;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
function WishCardInner({
|
||||
wish,
|
||||
view,
|
||||
onEdit,
|
||||
onArchive,
|
||||
onComplete,
|
||||
onRestore,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
footer,
|
||||
}: WishCardProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const completed = wish.status === 'COMPLETED';
|
||||
const priceLabel = formatPrice(wish.price, wish.currency);
|
||||
const imageSrc = wish.imageUrl ?? '/default-gift.svg';
|
||||
|
||||
const actions: WishCardAction[] = [];
|
||||
if (view === 'owner') {
|
||||
if (wish.status === 'ACTIVE') {
|
||||
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: 'Edit', onClick: onEdit });
|
||||
if (onComplete)
|
||||
actions.push({
|
||||
key: 'complete',
|
||||
icon: CheckCircle2,
|
||||
label: 'Mark fulfilled',
|
||||
onClick: onComplete,
|
||||
});
|
||||
if (onArchive)
|
||||
actions.push({ key: 'archive', icon: Archive, label: 'Archive', onClick: onArchive });
|
||||
if (onDelete)
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
danger: true,
|
||||
});
|
||||
} else if (wish.status === 'ARCHIVED') {
|
||||
if (onRestore)
|
||||
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
|
||||
if (onDelete)
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
danger: true,
|
||||
});
|
||||
} else if (wish.status === 'COMPLETED') {
|
||||
if (onDuplicate)
|
||||
actions.push({
|
||||
key: 'duplicate',
|
||||
icon: Copy,
|
||||
label: 'Create copy as new',
|
||||
onClick: onDuplicate,
|
||||
});
|
||||
if (onDelete)
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
danger: true,
|
||||
});
|
||||
} else if (wish.status === 'DELETED') {
|
||||
if (onRestore)
|
||||
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={cn('wish-card', completed && 'wish-card--completed')}>
|
||||
<div className="wish-card__image-wrap">
|
||||
<img
|
||||
className="wish-card__image"
|
||||
src={imageSrc}
|
||||
alt={wish.title}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).src = '/default-gift.svg';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-3 top-3">
|
||||
<WishBadges wish={wish} view={view} />
|
||||
</div>
|
||||
{actions.length > 0 && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Actions"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute right-0 z-20 mt-2 w-48 overflow-hidden rounded-md border border-border bg-surface shadow-pop">
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
a.onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-muted',
|
||||
a.danger && 'text-danger',
|
||||
)}
|
||||
>
|
||||
<a.icon className="h-4 w-4" />
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wish-card__body">
|
||||
<h3 className="wish-card__title">{wish.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{priceLabel && <span className="wish-card__price">{priceLabel}</span>}
|
||||
{wish.url && (
|
||||
<a
|
||||
href={wish.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
open link
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{wish.comment && <p className="wish-card__comment">{wish.comment}</p>}
|
||||
{footer}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export const WishCard = memo(WishCardInner);
|
||||
197
apps/frontend/src/components/WishForm/WishForm.tsx
Normal file
197
apps/frontend/src/components/WishForm/WishForm.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateWishInput>({
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add a wish' : 'Edit wish'}
|
||||
description={
|
||||
mode === 'create'
|
||||
? 'Tell us what you want. A link helps us grab a preview image automatically.'
|
||||
: 'Update the details of your wish.'
|
||||
}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="wish-form" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{mode === 'create' ? 'Add wish' : 'Save'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="wish-form" className="grid gap-4" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" placeholder="Moka pot, size 3" {...register('title')} />
|
||||
{errors.title && <span className="field__error">{errors.title.message}</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
|
||||
<div className="field">
|
||||
<Label htmlFor="price">Price (optional)</Label>
|
||||
<Input id="price" placeholder="e.g. 2490" inputMode="decimal" {...register('price')} />
|
||||
{errors.price && <span className="field__error">{errors.price.message as string}</span>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<Label htmlFor="url">Link (optional)</Label>
|
||||
<Input id="url" type="url" placeholder="https://..." {...register('url')} />
|
||||
{errors.url && <span className="field__error">{errors.url.message as string}</span>}
|
||||
<p className="text-xs text-muted">
|
||||
We will try to pull a preview image from the link after saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<Label htmlFor="comment">Comment (optional)</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
rows={3}
|
||||
placeholder="Size / color / notes..."
|
||||
{...register('comment')}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{canEditImage && activeId && (
|
||||
<section className="mt-6 rounded-md border border-border bg-surface-muted p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Image
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void upload.mutateAsync({ id: activeId, file });
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Upload custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshOg.mutateAsync(activeId)}
|
||||
disabled={refreshOg.isPending}
|
||||
>
|
||||
{refreshOg.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
Refresh from link
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void resetImage.mutateAsync(activeId)}
|
||||
disabled={resetImage.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
42
apps/frontend/src/components/ui/Button.tsx
Normal file
42
apps/frontend/src/components/ui/Button.tsx
Normal file
@@ -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<Variant, string> = {
|
||||
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<Size, string> = {
|
||||
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<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = 'primary', size = 'md', type = 'button', ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(base, variants[variant], sizes[size], className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
8
apps/frontend/src/components/ui/Input.tsx
Normal file
8
apps/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
function Input({ className, ...rest }, ref) {
|
||||
return <input ref={ref} className={cn('field__input', className)} {...rest} />;
|
||||
},
|
||||
);
|
||||
6
apps/frontend/src/components/ui/Label.tsx
Normal file
6
apps/frontend/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { LabelHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Label({ className, ...rest }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn('field__label', className)} {...rest} />;
|
||||
}
|
||||
78
apps/frontend/src/components/ui/Modal.tsx
Normal file
78
apps/frontend/src/components/ui/Modal.tsx
Normal file
@@ -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(
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6">
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/40 backdrop-blur-sm animate-fade-in-up"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'relative w-full bg-surface shadow-pop animate-fade-in-up',
|
||||
'rounded-t-xl sm:rounded-xl',
|
||||
size === 'md' ? 'sm:max-w-lg' : 'sm:max-w-2xl',
|
||||
'max-h-[90vh] overflow-hidden flex flex-col',
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-muted">{description}</p>}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="overflow-y-auto px-5 py-5">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
16
apps/frontend/src/components/ui/Textarea.tsx
Normal file
16
apps/frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const Textarea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(function Textarea({ className, rows = 3, ...rest }, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
className={cn('field__textarea', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
49
apps/frontend/src/features/auth/authStore.ts
Normal file
49
apps/frontend/src/features/auth/authStore.ts
Normal file
@@ -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<void>;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
status: 'idle',
|
||||
init: async () => {
|
||||
set({ status: 'loading' });
|
||||
try {
|
||||
const user = await api.get<AuthUser>('/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<AuthUser>('/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<AuthUser>('/api/auth/me');
|
||||
set({ user });
|
||||
} catch {
|
||||
set({ user: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
41
apps/frontend/src/features/wishes/wishes.api.ts
Normal file
41
apps/frontend/src/features/wishes/wishes.api.ts
Normal file
@@ -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<WishWithOwnerBadge[]>(`/api/wishes?status=${status}`),
|
||||
|
||||
get: (id: string) => api.get<Wish>(`/api/wishes/${id}`),
|
||||
|
||||
create: (input: CreateWishInput) => api.post<Wish, CreateWishInput>('/api/wishes', input),
|
||||
|
||||
update: (id: string, input: UpdateWishInput) =>
|
||||
api.patch<Wish, UpdateWishInput>(`/api/wishes/${id}`, input),
|
||||
|
||||
remove: (id: string) => api.delete<Wish>(`/api/wishes/${id}`),
|
||||
archive: (id: string) => api.post<Wish>(`/api/wishes/${id}/archive`),
|
||||
complete: (id: string) => api.post<Wish>(`/api/wishes/${id}/complete`),
|
||||
restore: (id: string) => api.post<Wish>(`/api/wishes/${id}/restore`),
|
||||
duplicate: (id: string) => api.post<Wish>(`/api/wishes/${id}/duplicate`),
|
||||
|
||||
uploadImage: (id: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name);
|
||||
return api.upload<Wish>(`/api/wishes/${id}/image`, fd);
|
||||
},
|
||||
refreshOg: (id: string) => api.post<Wish>(`/api/wishes/${id}/image/refresh-og`),
|
||||
deleteImage: (id: string) => api.delete<Wish>(`/api/wishes/${id}/image`),
|
||||
};
|
||||
|
||||
export function statusToQuery(s: WishStatus): OwnerStatus {
|
||||
return s.toLowerCase() as OwnerStatus;
|
||||
}
|
||||
145
apps/frontend/src/features/wishes/wishes.hooks.ts
Normal file
145
apps/frontend/src/features/wishes/wishes.hooks.ts
Normal file
@@ -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<typeof useQueryClient>): 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),
|
||||
});
|
||||
}
|
||||
70
apps/frontend/src/lib/api.ts
Normal file
70
apps/frontend/src/lib/api.ts
Normal file
@@ -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<TBody = unknown> {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: TBody;
|
||||
signal?: AbortSignal;
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
async function request<TResponse, TBody = unknown>(
|
||||
path: string,
|
||||
options: RequestOptions<TBody> = {},
|
||||
): Promise<TResponse> {
|
||||
const headers: Record<string, string> = {};
|
||||
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: <T>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
|
||||
post: <T, B = unknown>(path: string, body?: B) => request<T, B>(path, { method: 'POST', body }),
|
||||
patch: <T, B = unknown>(path: string, body?: B) =>
|
||||
request<T, B>(path, { method: 'PATCH', body }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
upload: <T>(path: string, formData: FormData) =>
|
||||
request<T>(path, { method: 'POST', formData }),
|
||||
};
|
||||
6
apps/frontend/src/lib/cn.ts
Normal file
6
apps/frontend/src/lib/cn.ts
Normal file
@@ -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));
|
||||
}
|
||||
25
apps/frontend/src/lib/format.ts
Normal file
25
apps/frontend/src/lib/format.ts
Normal file
@@ -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)));
|
||||
}
|
||||
4
apps/frontend/src/lib/version.ts
Normal file
4
apps/frontend/src/lib/version.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
declare const __FRONTEND_VERSION__: string;
|
||||
|
||||
export const FRONTEND_VERSION: string =
|
||||
typeof __FRONTEND_VERSION__ !== 'undefined' ? __FRONTEND_VERSION__ : '0.0.0';
|
||||
13
apps/frontend/src/main.tsx
Normal file
13
apps/frontend/src/main.tsx
Normal file
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
31
apps/frontend/src/routes.tsx
Normal file
31
apps/frontend/src/routes.tsx
Normal file
@@ -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: <LoginPage /> },
|
||||
{ path: '/u/:slug', element: <PublicProfilePage /> },
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/archive', element: <ArchivePage /> },
|
||||
{ path: '/completed', element: <CompletedPage /> },
|
||||
{ path: '/trash', element: <TrashPage /> },
|
||||
{ path: '/settings', element: <ProfileSettingsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
]);
|
||||
102
apps/frontend/src/styles/global.css
Normal file
102
apps/frontend/src/styles/global.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
35
apps/frontend/src/styles/tokens.css
Normal file
35
apps/frontend/src/styles/tokens.css
Normal file
@@ -0,0 +1,35 @@
|
||||
:root {
|
||||
/* Colors (RGB triplets so Tailwind <alpha-value> 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);
|
||||
}
|
||||
58
apps/frontend/tailwind.config.ts
Normal file
58
apps/frontend/tailwind.config.ts
Normal file
@@ -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) / <alpha-value>)',
|
||||
surface: 'rgb(var(--color-surface) / <alpha-value>)',
|
||||
'surface-muted': 'rgb(var(--color-surface-muted) / <alpha-value>)',
|
||||
primary: {
|
||||
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
|
||||
foreground: 'rgb(var(--color-primary-foreground) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
|
||||
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'rgb(var(--color-accent) / <alpha-value>)',
|
||||
foreground: 'rgb(var(--color-accent-foreground) / <alpha-value>)',
|
||||
},
|
||||
ink: 'rgb(var(--color-text) / <alpha-value>)',
|
||||
muted: 'rgb(var(--color-muted) / <alpha-value>)',
|
||||
border: 'rgb(var(--color-border) / <alpha-value>)',
|
||||
success: 'rgb(var(--color-success) / <alpha-value>)',
|
||||
warning: 'rgb(var(--color-warning) / <alpha-value>)',
|
||||
danger: 'rgb(var(--color-danger) / <alpha-value>)',
|
||||
},
|
||||
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;
|
||||
20
apps/frontend/tsconfig.app.json
Normal file
20
apps/frontend/tsconfig.app.json
Normal file
@@ -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/**/*"]
|
||||
}
|
||||
7
apps/frontend/tsconfig.json
Normal file
7
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
12
apps/frontend/tsconfig.node.json
Normal file
12
apps/frontend/tsconfig.node.json
Normal file
@@ -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"]
|
||||
}
|
||||
36
apps/frontend/vite.config.ts
Normal file
36
apps/frontend/vite.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user