Files
2026-07-13 13:32:57 +08:00

203 lines
6.5 KiB
TypeScript

import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
import { Form, type MetaFunction, useActionData } from "@remix-run/react";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { updateUser } from "~/models/user.server";
import { requireUserId } from "~/services/session.server";
import { accountPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Your profile | Trigger.dev`,
},
];
};
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conformZodMessage.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parseWithZod(formData, { schema: formSchema, async: true });
if (submission.status !== "success") {
return json(submission.reply());
}
try {
const _user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: createSchema() });
},
});
return (
<PageContainer>
<NavBar>
<PageTitle title="Your profile" />
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer className="grid place-items-center">
<div className="mb-3 w-full border-b border-grid-dimmed pb-3">
<Header2>Profile</Header2>
</div>
<Form method="post" {...getFormProps(form)} className="w-full">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="size-24" />
</InputGroup>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...getInputProps(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon={AvatarCircleIcon}
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.errors}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...getInputProps(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon={EnvelopeIcon}
/>
<FormError id={email.errorId}>{email.errors}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<CheckboxWithLabel
{...getInputProps(marketingEmails, { type: "checkbox" })}
label="Receive onboarding emails"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.errors}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"secondary/small"}>
Update
</Button>
}
/>
</Fieldset>
</Form>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}