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