feat(frontend): add react spa with wishlist flows and public profile

This commit is contained in:
Anton
2026-04-23 16:05:27 +03:00
parent 5f6a551b6c
commit 00f01611ed
44 changed files with 2166 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}</>;
}