feat(frontend): add react spa with wishlist flows and public profile
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user