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

PropTypeDefault
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));}