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