Files
Reactive-Resume/src/routes/dashboard/settings/profile.tsx
T
Amruth Pillai 08e9c80037 refactor(ui): improve error handling and input safety in app flows
Normalize frontend error rendering and tighten input/path handling across auth, builder, dashboard, and shared components for more resilient UX behavior.

Made-with: Cursor
2026-04-25 15:31:13 +02:00

284 lines
8.8 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckIcon, UserCircleIcon, WarningIcon } from "@phosphor-icons/react";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { match } from "ts-pattern";
import z from "zod";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { authClient } from "@/integrations/auth/client";
import { getReadableErrorMessage } from "@/utils/error-message";
import { DashboardHeader } from "../-components/header";
export const Route = createFileRoute("/dashboard/settings/profile")({
component: RouteComponent,
});
const formSchema = z.object({
name: z.string().trim().min(1).max(64),
username: z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9._-]+$/, {
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
}),
email: z.email().trim(),
});
type FormValues = z.infer<typeof formSchema>;
function RouteComponent() {
const router = useRouter();
const { session } = Route.useRouteContext();
const defaultValues = useMemo(() => {
return {
name: session.user.name,
username: session.user.username,
email: session.user.email,
};
}, [session.user]);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
});
const onCancel = () => {
form.reset(defaultValues);
};
const onSubmit = async (data: FormValues) => {
const { error } = await authClient.updateUser({
name: data.name,
username: data.username,
displayUsername: data.username,
});
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when updating profile details fails",
message: "Failed to update your profile. Please try again.",
}),
),
);
return;
}
toast.success(t`Your profile has been updated successfully.`);
form.reset({ name: data.name, username: data.username, email: session.user.email });
void router.invalidate();
if (data.email !== session.user.email) {
const { error } = await authClient.changeEmail({
newEmail: data.email,
callbackURL: "/dashboard/settings/profile",
});
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when requesting email change confirmation fails",
message: "Failed to request email change. Please try again.",
}),
),
);
return;
}
toast.success(
t`A confirmation link has been sent to your current email address. Please check your inbox to confirm the change.`,
);
form.reset({ name: data.name, username: data.username, email: session.user.email });
void router.invalidate();
}
};
const handleResendVerificationEmail = async () => {
const toastId = toast.loading(t`Resending verification email...`);
const { error } = await authClient.sendVerificationEmail({
email: session.user.email,
callbackURL: "/dashboard/settings/profile",
});
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when resending account verification email fails",
message: "Failed to resend verification email. Please try again.",
}),
),
{ id: toastId },
);
return;
}
toast.success(
t`A new verification link has been sent to your email address. Please check your inbox to verify your account.`,
{ id: toastId },
);
void router.invalidate();
};
return (
<div className="space-y-4">
<DashboardHeader icon={UserCircleIcon} title={t`Profile`} />
<Separator />
<Form {...form}>
<motion.form
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
min={3}
max={64}
autoComplete="name"
placeholder={t({
comment: "Example full name placeholder on profile settings form",
message: "John Doe",
})}
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<FormControl
render={
<Input
min={3}
max={64}
autoComplete="username"
placeholder={t({
comment: "Example username placeholder on profile settings form",
message: "john.doe",
})}
className="lowercase"
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl
render={
<Input
type="email"
autoComplete="email"
placeholder={t({
comment: "Example email placeholder on profile settings form",
message: "john.doe@example.com",
})}
className="lowercase"
{...field}
/>
}
/>
<FormMessage />
{match(session.user.emailVerified)
.with(true, () => (
<p className="flex items-center gap-x-1.5 text-xs text-green-700">
<CheckIcon />
<Trans>Verified</Trans>
</p>
))
.with(false, () => (
<p className="flex items-center gap-x-1.5 text-xs text-amber-600">
<WarningIcon className="size-3.5" />
<Trans>Unverified</Trans>
<span>|</span>
<Button
variant="link"
className="h-auto gap-x-1.5 p-0! text-xs text-inherit"
onClick={handleResendVerificationEmail}
>
<Trans>Resend verification email</Trans>
</Button>
</p>
))
.exhaustive()}
</FormItem>
)}
/>
<AnimatePresence initial={false} mode="popLayout">
{form.formState.isDirty && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
>
<Button type="reset" variant="ghost" onClick={onCancel}>
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Save Changes</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
</motion.form>
</Form>
</div>
);
}