203 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|