Account Settings
Installation
Add the following Soul components
The account-settings component uses the input and button components. Make sure you have added them to your project.
Install the following dependencies
npm install @conform-to/zod @conform-to/react
Copy and paste the following code into your project
sections/account-settings/index.tsx
import { ChangePasswordAction, ChangePasswordForm } from './change-password-form';import { Account, UpdateAccountAction, UpdateAccountForm } from './update-account-form';export interface AccountSettingsSectionProps { title?: string; account: Account; updateAccountAction: UpdateAccountAction; updateAccountSubmitLabel?: string; changePasswordTitle?: string; changePasswordAction: ChangePasswordAction; changePasswordSubmitLabel?: string;}/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { * --account-settings-section-font-family: var(--font-family-heading); * --account-settings-section-text: hsl(var(--foreground)); * --account-settings-section-border: hsl(var(--contrast-100)); * } * ``` */export function AccountSettingsSection({ title = 'Account Settings', account, updateAccountAction, updateAccountSubmitLabel, changePasswordTitle = 'Change Password', changePasswordAction, changePasswordSubmitLabel,}: AccountSettingsSectionProps) { return ( <div className="@container"> <div className="flex flex-col gap-y-24 @xl:flex-row"> <div className="flex w-full flex-col @xl:max-w-lg"> <div className="pb-12"> <h1 className="mb-10 font-[family-name:var(--account-settings-section-font-family,var(--font-family-heading))] text-4xl font-medium leading-none text-[var(--account-settings-section-text,var(--foreground))] @xl:text-4xl"> {title} </h1> <UpdateAccountForm account={account} action={updateAccountAction} submitLabel={updateAccountSubmitLabel} /> </div> <div className="border-t border-[var(--account-settings-section-border,hsl(var(--contrast-100)))] pt-12"> <h1 className="mb-10 font-[family-name:var(--account-settings-section-font-family,var(--font-family-heading))] text-2xl font-medium leading-none text-[var(--account-settings-section-text,var(--foreground))] @xl:text-2xl"> {changePasswordTitle} </h1> <ChangePasswordForm action={changePasswordAction} submitLabel={changePasswordSubmitLabel} /> </div> </div> </div> </div> );}
sections/account-settings/change-password-form.tsx
'use client';import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react';import { getZodConstraint, parseWithZod } from '@conform-to/zod';import { ReactNode, useActionState, useEffect } from 'react';import { useFormStatus } from 'react-dom';import { Input } from '@/vibes/soul/form/input';import { Button } from '@/vibes/soul/primitives/button';import { changePasswordSchema } from './schema';type Action<State, Payload> = (state: Awaited<State>, payload: Payload) => State | Promise<State>;export type ChangePasswordAction = Action<SubmissionResult | null, FormData>;export interface ChangePasswordFormProps { action: ChangePasswordAction; currentPasswordLabel?: string; newPasswordLabel?: string; confirmPasswordLabel?: string; submitLabel?: string;}export function ChangePasswordForm({ action, currentPasswordLabel = 'Current password', newPasswordLabel = 'New password', confirmPasswordLabel = 'Confirm password', submitLabel = 'Update',}: ChangePasswordFormProps) { const [lastResult, formAction] = useActionState(action, null); const [form, fields] = useForm({ constraint: getZodConstraint(changePasswordSchema), shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { return parseWithZod(formData, { schema: changePasswordSchema }); }, }); useEffect(() => { if (lastResult?.error) { console.log(lastResult.error); } }, [lastResult]); return ( <form {...getFormProps(form)} action={formAction} className="space-y-5"> <Input {...getInputProps(fields.currentPassword, { type: 'password' })} errors={fields.currentPassword.errors} key={fields.currentPassword.id} label={currentPasswordLabel} /> <Input {...getInputProps(fields.password, { type: 'password' })} errors={fields.password.errors} key={fields.password.id} label={newPasswordLabel} /> <Input {...getInputProps(fields.confirmPassword, { type: 'password' })} className="mb-6" errors={fields.confirmPassword.errors} key={fields.confirmPassword.id} label={confirmPasswordLabel} /> <SubmitButton>{submitLabel}</SubmitButton> </form> );}function SubmitButton({ children }: { children: ReactNode }) { const { pending } = useFormStatus(); return ( <Button loading={pending} size="small" type="submit" variant="secondary"> {children} </Button> );}
sections/account-settings/update-account-form.tsx
'use client';import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react';import { getZodConstraint, parseWithZod } from '@conform-to/zod';import { useActionState, useEffect, useOptimistic, useTransition } from 'react';import { z } from 'zod';import { Input } from '@/vibes/soul/form/input';import { Button } from '@/vibes/soul/primitives/button';import { toast } from '@/vibes/soul/primitives/toaster';import { updateAccountSchema } from './schema';type Action<S, P> = (state: Awaited<S>, payload: P) => S | Promise<S>;export type UpdateAccountAction = Action<State, FormData>;export type Account = z.infer<typeof updateAccountSchema>;interface State { account: Account; successMessage?: string; lastResult: SubmissionResult | null;}export interface UpdateAccountFormProps { action: UpdateAccountAction; account: Account; firstNameLabel?: string; lastNameLabel?: string; emailLabel?: string; companyLabel?: string; submitLabel?: string;}export function UpdateAccountForm({ action, account, firstNameLabel = 'First name', lastNameLabel = 'Last name', emailLabel = 'Email', companyLabel = 'Company', submitLabel = 'Update',}: UpdateAccountFormProps) { const [state, formAction] = useActionState(action, { account, lastResult: null }); const [pending, startTransition] = useTransition(); const [optimisticState, setOptimisticState] = useOptimistic<State, FormData>( state, (prevState, formData) => { const intent = formData.get('intent'); const submission = parseWithZod(formData, { schema: updateAccountSchema }); if (submission.status !== 'success') return prevState; switch (intent) { case 'update': { return { ...prevState, account: submission.value, }; } default: return prevState; } }, ); const [form, fields] = useForm({ lastResult: state.lastResult, defaultValue: optimisticState.account, constraint: getZodConstraint(updateAccountSchema), shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { return parseWithZod(formData, { schema: updateAccountSchema }); }, }); useEffect(() => { if (state.lastResult?.status === 'success' && typeof state.successMessage === 'string') { toast.success(state.successMessage); } }, [state]); return ( <form {...getFormProps(form)} action={(formData) => { startTransition(() => { formAction(formData); setOptimisticState(formData); }); }} className="space-y-5" > <div className="flex gap-5"> <Input {...getInputProps(fields.firstName, { type: 'text' })} errors={fields.firstName.errors} key={fields.firstName.id} label={firstNameLabel} /> <Input {...getInputProps(fields.lastName, { type: 'text' })} errors={fields.lastName.errors} key={fields.lastName.id} label={lastNameLabel} /> </div> <Input {...getInputProps(fields.email, { type: 'text' })} errors={fields.email.errors} key={fields.email.id} label={emailLabel} /> <Input {...getInputProps(fields.company, { type: 'text' })} errors={fields.company.errors} key={fields.company.id} label={companyLabel} /> <Button loading={pending} name="intent" size="small" type="submit" value="update" variant="secondary" > {submitLabel} </Button> </form> );}
sections/account-settings/schema.ts
import { z } from 'zod';export const updateAccountSchema = z.object({ firstName: z.string().min(2, { message: 'Name must be at least 2 characters long.' }).trim(), lastName: z.string().min(2, { message: 'Name must be at least 2 characters long.' }).trim(), email: z.string().email({ message: 'Please enter a valid email.' }).trim(), company: z.string().trim().optional(),});export const changePasswordSchema = z .object({ currentPassword: z.string().trim(), password: z .string() .min(8, { message: 'Be at least 8 characters long' }) .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' }) .regex(/[0-9]/, { message: 'Contain at least one number.' }) .regex(/[^a-zA-Z0-9]/, { message: 'Contain at least one special character.', }) .trim(), confirmPassword: z.string(), }) .superRefine(({ confirmPassword, password }, ctx) => { if (confirmPassword !== password) { ctx.addIssue({ code: 'custom', message: 'The passwords did not match', path: ['confirmPassword'], }); } });
Usage
import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings';function Usage() { return ( <AccountSettingsSection account={{ firstName: '', lastName: '', email: '' }} changePasswordAction={changePasswordAction} updateAccountAction={updateAccountAction} /> );}
API Reference
AccountSettingsProps
Prop | Type | Default |
---|---|---|
title | string | 'Account Settings' |
account* | Account | |
updateAccountAction* | UpdateAccountAction | |
updateAccountSubmitLabel | string | |
changePasswordTitle | string | 'Change Password' |
changePasswordAction* | ChangePasswordAction | |
changePasswordSubmitLabel | string |
CSS Variables
This component supports various CSS variables for theming. Here's a comprehensive list.
:root { --account-settings-section-font-family: var(--font-family-heading); --account-settings-section-text: hsl(var(--foreground)); --account-settings-section-border: hsl(var(--contrast-100));}