From 00f01611ede2fb5ee096a56198c84d3dedea7f49 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 23 Apr 2026 16:05:27 +0300 Subject: [PATCH] feat(frontend): add react spa with wishlist flows and public profile --- apps/frontend/.dockerignore | 5 + apps/frontend/index.html | 20 ++ apps/frontend/package.json | 39 ++++ apps/frontend/postcss.config.js | 6 + apps/frontend/public/default-gift.svg | 37 ++++ apps/frontend/public/empty-state.svg | 28 +++ apps/frontend/public/favicon.svg | 12 ++ apps/frontend/src/App.tsx | 34 +++ .../src/components/Layout/AppShell.tsx | 15 ++ .../frontend/src/components/Layout/Footer.tsx | 32 +++ .../frontend/src/components/Layout/Header.tsx | 79 +++++++ .../src/components/Layout/ProtectedRoute.tsx | 17 ++ .../src/components/WishBadges/WishBadges.tsx | 51 +++++ .../src/components/WishCard/WishCard.tsx | 191 +++++++++++++++++ .../src/components/WishForm/WishForm.tsx | 197 ++++++++++++++++++ apps/frontend/src/components/ui/Button.tsx | 42 ++++ apps/frontend/src/components/ui/Input.tsx | 8 + apps/frontend/src/components/ui/Label.tsx | 6 + apps/frontend/src/components/ui/Modal.tsx | 78 +++++++ apps/frontend/src/components/ui/Textarea.tsx | 16 ++ apps/frontend/src/features/auth/authStore.ts | 49 +++++ .../src/features/wishes/wishes.api.ts | 41 ++++ .../src/features/wishes/wishes.hooks.ts | 145 +++++++++++++ apps/frontend/src/lib/api.ts | 70 +++++++ apps/frontend/src/lib/cn.ts | 6 + apps/frontend/src/lib/format.ts | 25 +++ apps/frontend/src/lib/version.ts | 4 + apps/frontend/src/main.tsx | 13 ++ apps/frontend/src/pages/ArchivePage.tsx | 48 +++++ apps/frontend/src/pages/CompletedPage.tsx | 50 +++++ apps/frontend/src/pages/DashboardPage.tsx | 102 +++++++++ apps/frontend/src/pages/LoginPage.tsx | 98 +++++++++ apps/frontend/src/pages/NotFoundPage.tsx | 16 ++ .../src/pages/ProfileSettingsPage.tsx | 121 +++++++++++ apps/frontend/src/pages/PublicProfilePage.tsx | 110 ++++++++++ apps/frontend/src/pages/TrashPage.tsx | 54 +++++ apps/frontend/src/routes.tsx | 31 +++ apps/frontend/src/styles/global.css | 102 +++++++++ apps/frontend/src/styles/tokens.css | 35 ++++ apps/frontend/tailwind.config.ts | 58 ++++++ apps/frontend/tsconfig.app.json | 20 ++ apps/frontend/tsconfig.json | 7 + apps/frontend/tsconfig.node.json | 12 ++ apps/frontend/vite.config.ts | 36 ++++ 44 files changed, 2166 insertions(+) create mode 100644 apps/frontend/.dockerignore create mode 100644 apps/frontend/index.html create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/postcss.config.js create mode 100644 apps/frontend/public/default-gift.svg create mode 100644 apps/frontend/public/empty-state.svg create mode 100644 apps/frontend/public/favicon.svg create mode 100644 apps/frontend/src/App.tsx create mode 100644 apps/frontend/src/components/Layout/AppShell.tsx create mode 100644 apps/frontend/src/components/Layout/Footer.tsx create mode 100644 apps/frontend/src/components/Layout/Header.tsx create mode 100644 apps/frontend/src/components/Layout/ProtectedRoute.tsx create mode 100644 apps/frontend/src/components/WishBadges/WishBadges.tsx create mode 100644 apps/frontend/src/components/WishCard/WishCard.tsx create mode 100644 apps/frontend/src/components/WishForm/WishForm.tsx create mode 100644 apps/frontend/src/components/ui/Button.tsx create mode 100644 apps/frontend/src/components/ui/Input.tsx create mode 100644 apps/frontend/src/components/ui/Label.tsx create mode 100644 apps/frontend/src/components/ui/Modal.tsx create mode 100644 apps/frontend/src/components/ui/Textarea.tsx create mode 100644 apps/frontend/src/features/auth/authStore.ts create mode 100644 apps/frontend/src/features/wishes/wishes.api.ts create mode 100644 apps/frontend/src/features/wishes/wishes.hooks.ts create mode 100644 apps/frontend/src/lib/api.ts create mode 100644 apps/frontend/src/lib/cn.ts create mode 100644 apps/frontend/src/lib/format.ts create mode 100644 apps/frontend/src/lib/version.ts create mode 100644 apps/frontend/src/main.tsx create mode 100644 apps/frontend/src/pages/ArchivePage.tsx create mode 100644 apps/frontend/src/pages/CompletedPage.tsx create mode 100644 apps/frontend/src/pages/DashboardPage.tsx create mode 100644 apps/frontend/src/pages/LoginPage.tsx create mode 100644 apps/frontend/src/pages/NotFoundPage.tsx create mode 100644 apps/frontend/src/pages/ProfileSettingsPage.tsx create mode 100644 apps/frontend/src/pages/PublicProfilePage.tsx create mode 100644 apps/frontend/src/pages/TrashPage.tsx create mode 100644 apps/frontend/src/routes.tsx create mode 100644 apps/frontend/src/styles/global.css create mode 100644 apps/frontend/src/styles/tokens.css create mode 100644 apps/frontend/tailwind.config.ts create mode 100644 apps/frontend/tsconfig.app.json create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/frontend/tsconfig.node.json create mode 100644 apps/frontend/vite.config.ts diff --git a/apps/frontend/.dockerignore b/apps/frontend/.dockerignore new file mode 100644 index 0000000..0ebf8c6 --- /dev/null +++ b/apps/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.* +*.log diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..eff4ca2 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + Family Wishlist + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..46f9b28 --- /dev/null +++ b/apps/frontend/package.json @@ -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" + } +} diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/frontend/public/default-gift.svg b/apps/frontend/public/default-gift.svg new file mode 100644 index 0000000..111a87a --- /dev/null +++ b/apps/frontend/public/default-gift.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/public/empty-state.svg b/apps/frontend/public/empty-state.svg new file mode 100644 index 0000000..9783df1 --- /dev/null +++ b/apps/frontend/public/empty-state.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg new file mode 100644 index 0000000..8ae58ff --- /dev/null +++ b/apps/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..3558194 --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -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 ( + + + + + + + ); +} diff --git a/apps/frontend/src/components/Layout/AppShell.tsx b/apps/frontend/src/components/Layout/AppShell.tsx new file mode 100644 index 0000000..d29eab1 --- /dev/null +++ b/apps/frontend/src/components/Layout/AppShell.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; +import { Header } from './Header'; +import { Footer } from './Footer'; + +export function AppShell() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/components/Layout/Footer.tsx b/apps/frontend/src/components/Layout/Footer.tsx new file mode 100644 index 0000000..76d9484 --- /dev/null +++ b/apps/frontend/src/components/Layout/Footer.tsx @@ -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('/api/version'), + staleTime: 10 * 60 * 1000, + }); + + return ( +
+
+
+ + Family Wishlist +
+
+ frontend v{FRONTEND_VERSION} + ยท + backend v{data?.backend ?? '...'} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/Layout/Header.tsx b/apps/frontend/src/components/Layout/Header.tsx new file mode 100644 index 0000000..434c4d2 --- /dev/null +++ b/apps/frontend/src/components/Layout/Header.tsx @@ -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 ( +
+
+ + + + +
+
Family Wishlist
+
+ signed in as {user.displayName} +
+
+ + + + +
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/components/Layout/ProtectedRoute.tsx b/apps/frontend/src/components/Layout/ProtectedRoute.tsx new file mode 100644 index 0000000..be144d1 --- /dev/null +++ b/apps/frontend/src/components/Layout/ProtectedRoute.tsx @@ -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 ( +
Loading...
+ ); + } + if (!user) { + return ; + } + return <>{children}; +} diff --git a/apps/frontend/src/components/WishBadges/WishBadges.tsx b/apps/frontend/src/components/WishBadges/WishBadges.tsx new file mode 100644 index 0000000..4c1da65 --- /dev/null +++ b/apps/frontend/src/components/WishBadges/WishBadges.tsx @@ -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( + + + new + , + ); + } + if (wish.status === 'COMPLETED') { + badges.push( + + + fulfilled + , + ); + } + if (wish.status === 'ARCHIVED' && view === 'owner') { + badges.push( + + + archived + , + ); + } + if (wish.status === 'DELETED' && view === 'owner') { + badges.push( + + + trash + , + ); + } + + if (badges.length === 0) return null; + return
{badges}
; +} diff --git a/apps/frontend/src/components/WishCard/WishCard.tsx b/apps/frontend/src/components/WishCard/WishCard.tsx new file mode 100644 index 0000000..43b9e19 --- /dev/null +++ b/apps/frontend/src/components/WishCard/WishCard.tsx @@ -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 ( +
+
+ {wish.title} { + (e.currentTarget as HTMLImageElement).src = '/default-gift.svg'; + }} + /> +
+ +
+ {actions.length > 0 && ( +
+
+ + {menuOpen && ( + <> +
setMenuOpen(false)} + aria-hidden + /> +
+ {actions.map((a) => ( + + ))} +
+ + )} +
+
+ )} +
+
+

{wish.title}

+
+ {priceLabel && {priceLabel}} + {wish.url && ( + + + open link + + )} +
+ {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 ( + + + + + } + > +
+
+ + + {errors.title && {errors.title.message}} +
+ +
+
+ + + {errors.price && {errors.price.message as string}} +
+
+ + +
+
+ +
+ + + {errors.url && {errors.url.message as string}} +

+ We will try to pull a preview image from the link after saving. +

+
+ +
+ +