All templates
Settings form
Form - Settings
A long settings page split into sections, with each setting described on the left and its control on the right. Save sits in the header and enables once something changes. Form, settings, preferences, toggle, switch, select, duration, textarea, required fields.
npx @adaptavant/edscli templates settings-formPreview
Open in new tabCode
'use client';
import {
Avatar,
Button,
CalDynamicIcon,
Field,
Heading,
IconButton,
type IconProps,
MenuItem,
SelectMenu,
SelectMenuItem,
SelectMenuListbox,
SelectMenuPopover,
SelectMenuTrigger,
Text,
Textarea,
TextInput,
Toggle,
Tooltip,
} from '@adaptavant/eds-core';
import {
AddIcon,
ChevronLeftIcon,
ConnectIcon,
CustomerIcon,
GrowYourBrandIcon,
InformationIcon,
IntegrationsIcon,
MenuIcon,
MoneyIcon,
ServicesIcon,
SetmoreIcon,
SettingsIcon,
UpdatesIcon,
} from '@adaptavant/eds-icons/icons';
import * as React from 'react';
const NAV_ITEMS = [
{
label: 'Calendar',
icon: ({ size }: IconProps) => (
<CalDynamicIcon size={size}>{new Date().getDate()}</CalDynamicIcon>
),
},
{ label: 'Services', icon: ServicesIcon },
{ label: 'Connect', icon: ConnectIcon },
{ label: 'Customers', icon: CustomerIcon },
{ label: 'Payments', icon: MoneyIcon },
{ label: 'Integrations', icon: IntegrationsIcon },
{ label: 'Updates', icon: UpdatesIcon },
{ label: 'Settings', icon: SettingsIcon },
];
/**
* The app frame: a collapsible navigation sidebar beside a main area that
* scrolls on its own. Pass the current page as `children`.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [activeNav, setActiveNav] = React.useState('Settings');
const [isNavCollapsed, setIsNavCollapsed] = React.useState(false);
return (
<div className="flex h-screen overflow-hidden bg-canvas text-primary">
<NavSidebar
activeNav={activeNav}
isCollapsed={isNavCollapsed}
onNavigate={setActiveNav}
onToggleCollapsed={() => setIsNavCollapsed((collapsed) => !collapsed)}
/>
{/* The sidebar is fixed, so the main area is offset by its width. */}
<main
className={`min-w-0 flex-1 overflow-y-auto transition-[margin] duration-200 ease-in-out ${
isNavCollapsed ? 'ms-[57px]' : 'ms-[216px]'
}`}
>
{children}
</main>
</div>
);
}
const MINUTES_HOURS = [
{ label: 'Minutes', value: 'minutes' },
{ label: 'Hours', value: 'hours' },
];
const DAYS_WEEKS = [
{ label: 'Days', value: 'days' },
{ label: 'Weeks', value: 'weeks' },
{ label: 'Months', value: 'months' },
];
const CANCELLATION_OPTIONS = [
{ label: 'Anytime', value: 'anytime' },
{ label: '24 hours before', value: '24h' },
{ label: '48 hours before', value: '48h' },
];
const LANGUAGES = [
{ label: 'English (US)', value: 'en-US' },
{ label: 'English (UK)', value: 'en-GB' },
{ label: 'Español', value: 'es' },
{ label: 'Français', value: 'fr' },
];
const TIME_FORMATS = [
{ label: '12 Hours', value: '12' },
{ label: '24 Hours', value: '24' },
];
const WEEK_STARTS = [
{ label: 'Sunday', value: 'sunday' },
{ label: 'Monday', value: 'monday' },
];
const SECTIONS = [
'Book appointment',
'Book class',
'About us',
'Our team',
'Services',
'Classes',
];
const FLOW_OPTIONS = [
{
key: 'customerLogin',
label: 'Customer login',
info: 'Customers sign in before booking.',
},
{
key: 'hideBranding',
label: "Hide Setmore's branding",
info: 'Removes the Setmore footer from your Booking Page.',
},
{
key: 'accordionView',
label: 'Accordion view',
info: 'Groups services into collapsible categories.',
},
{
key: 'onlineRescheduling',
label: 'Allow online rescheduling',
info: 'Customers can move their own appointments.',
},
{
key: 'onlineCancellations',
label: 'Allow online cancellations',
info: 'Customers can cancel their own appointments.',
},
{
key: 'bookNewButton',
label: '‘Book new appointment’ button',
info: 'Shows a button to book again after confirming.',
},
] as const;
const CONTACT_FIELDS = [
{ field: 'phone', label: 'Phone' },
{ field: 'email', label: 'Email' },
{ field: 'address', label: 'Address' },
] as const;
const INITIAL_PREFS = {
leadTime: '0',
leadTimeUnit: 'minutes',
schedulingWindow: '0',
schedulingWindowUnit: 'days',
slotSize: '15',
slotSizeUnit: 'minutes',
cancellation: 'anytime',
policy: '',
policyOnHome: false,
sections: Object.fromEntries(SECTIONS.map((section) => [section, true])),
firstAvailable: true,
skipTeamMembers: false,
multipleServices: true,
anyTeamMember: false,
customerLogin: true,
customerLoginRequired: false,
hideBranding: false,
accordionView: true,
onlineRescheduling: true,
onlineCancellations: true,
bookNewButton: false,
contactFields: {
phone: { shown: true, required: true },
email: { shown: true, required: false },
address: { shown: false, required: false },
},
language: 'en-US',
timeFormat: '12',
weekStart: 'monday',
};
type Prefs = typeof INITIAL_PREFS;
/** Turns off any setting whose toggle is disabled, so it is never saved on. */
const clearDisabled = (prefs: Prefs): Prefs => ({
...prefs,
policyOnHome: prefs.policyOnHome && Boolean(prefs.policy.trim()),
skipTeamMembers: prefs.skipTeamMembers && !prefs.multipleServices,
anyTeamMember: prefs.anyTeamMember && !prefs.multipleServices,
customerLoginRequired: prefs.customerLoginRequired && prefs.customerLogin,
contactFields: Object.fromEntries(
Object.entries(prefs.contactFields).map(([field, { shown, required }]) => [
field,
{ shown, required: required && shown },
])
) as Prefs['contactFields'],
});
/**
* A settings page split into sections. Each setting has its label and
* description on the left and its control on the right. Save is enabled once
* anything differs from the last save.
*/
export default function SettingsFormTemplate() {
const [saved, setSaved] = React.useState(INITIAL_PREFS);
const [prefs, setPrefs] = React.useState(INITIAL_PREFS);
const isDirty = JSON.stringify(prefs) !== JSON.stringify(saved);
const set = <K extends keyof Prefs>(key: K, value: Prefs[K]) =>
setPrefs((current) => clearDisabled({ ...current, [key]: value }));
const bindValue =
(
key: {
[K in keyof Prefs]: Prefs[K] extends string ? K : never;
}[keyof Prefs]
) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
set(key, event.target.value);
const bindOption =
(
key: {
[K in keyof Prefs]: Prefs[K] extends string ? K : never;
}[keyof Prefs]
) =>
(value: string) =>
set(key, value);
const bindChecked =
(
key: {
[K in keyof Prefs]: Prefs[K] extends boolean ? K : never;
}[keyof Prefs]
) =>
(event: React.ChangeEvent<HTMLInputElement>) =>
set(key, event.target.checked);
return (
<AppShell>
<form
className="min-h-full bg-canvas px-6"
onSubmit={(event) => {
event.preventDefault();
setSaved(prefs);
}}
>
<header className="sticky top-0 z-10 flex items-center justify-between gap-4 border-b border-solid border-tertiary bg-canvas py-3">
<Heading as="h1" className="text-heading-20">
Booking preferences
</Heading>
<Button
isDisabled={!isDirty}
type="submit"
variant="neutralSecondary"
>
Save
</Button>
</header>
<div className="flex max-w-[716px] flex-col gap-6 py-4">
<FormSection title="Booking policies">
<SettingRow
description="How much notice do you require before an appointment?"
label="Lead time"
>
<DurationInput
amount={prefs.leadTime}
label="Lead time"
onAmountChange={bindValue('leadTime')}
onUnitChange={bindOption('leadTimeUnit')}
unit={prefs.leadTimeUnit}
units={MINUTES_HOURS}
/>
</SettingRow>
<SettingRow
description="How far in advance can customers schedule an appointment?"
label="Scheduling window"
>
<DurationInput
amount={prefs.schedulingWindow}
label="Scheduling window"
onAmountChange={bindValue('schedulingWindow')}
onUnitChange={bindOption('schedulingWindowUnit')}
unit={prefs.schedulingWindowUnit}
units={DAYS_WEEKS}
/>
</SettingRow>
<SettingRow
description="How often should available booking slots appear?"
info="The gap between start times customers can pick."
label="Booking slot size"
>
<DurationInput
amount={prefs.slotSize}
label="Booking slot size"
onAmountChange={bindValue('slotSize')}
onUnitChange={bindOption('slotSizeUnit')}
unit={prefs.slotSizeUnit}
units={MINUTES_HOURS}
/>
</SettingRow>
<SettingRow
description="How soon before an appointment can customers reschedule or cancel?"
label="Cancellation policy"
>
<SelectControl
label="Cancellation policy"
onChange={bindOption('cancellation')}
options={CANCELLATION_OPTIONS}
value={prefs.cancellation}
/>
</SettingRow>
<div className="flex flex-col gap-3">
<SettingText
description="Share need-to-know details — about amending bookings, refunds and more — before customers confirm their bookings."
label="Booking policy"
/>
<Field label="Booking policy" labelVisibility="hidden">
<Textarea
onChange={bindValue('policy')}
placeholder="Type your policy or share a link"
value={prefs.policy}
/>
</Field>
{/* Nothing to pin until a policy is written. */}
<Toggle
checked={prefs.policyOnHome}
description="Display your booking policy at the top of your Booking Page to draw extra attention."
isDisabled={!prefs.policy.trim()}
label="Add policy to home"
onChange={bindChecked('policyOnHome')}
/>
</div>
</FormSection>
<Divider />
<FormSection title="Booking setup">
<ToggleGroup
description="What sections will be visible to your Booking Page visitors?"
title="Sections"
>
{SECTIONS.map((section) => (
<Toggle
checked={prefs.sections[section]}
key={section}
label={section}
onChange={(event) =>
set('sections', {
...prefs.sections,
[section]: event.target.checked,
})
}
/>
))}
</ToggleGroup>
<ToggleGroup
description="Streamline the scheduling experience to fill your calendar faster."
title="Booking flow"
>
<ToggleWithInfo
checked={prefs.firstAvailable}
info="Offers the earliest open slot first."
label="First available appointment"
onChange={bindChecked('firstAvailable')}
/>
{/* Both conflict with booking several services at once. */}
<ToggleWithInfo
checked={prefs.skipTeamMembers}
hint={
prefs.multipleServices
? 'To enable this, switch off ‘Provide multiple services’.'
: undefined
}
info="Customers skip choosing a team member."
isDisabled={prefs.multipleServices}
label="Skip team members"
onChange={bindChecked('skipTeamMembers')}
/>
<ToggleWithInfo
checked={prefs.multipleServices}
info="Customers can book several services in one visit."
label="Provide multiple services"
onChange={bindChecked('multipleServices')}
/>
<ToggleWithInfo
checked={prefs.anyTeamMember}
hint={
prefs.multipleServices
? 'To enable this, switch off ‘Provide multiple services’.'
: undefined
}
info="Customers can book with whoever is free."
isDisabled={prefs.multipleServices}
label="Any team member"
onChange={bindChecked('anyTeamMember')}
/>
{FLOW_OPTIONS.map(({ key, label, info }) => (
<div
className="flex items-center justify-between gap-4"
key={key}
>
<ToggleWithInfo
checked={prefs[key]}
info={info}
label={label}
onChange={bindChecked(key)}
/>
{key === 'customerLogin' ? (
<Toggle
checked={prefs.customerLoginRequired}
isDisabled={!prefs.customerLogin}
label="Required"
onChange={bindChecked('customerLoginRequired')}
/>
) : null}
</div>
))}
</ToggleGroup>
<ToggleGroup
description="What form fields will display when customers book online?"
title="Contact fields"
>
{/* Name is always collected. */}
<Toggle checked isDisabled label="Name" />
{CONTACT_FIELDS.map(({ field, label }) => {
const { shown, required } = prefs.contactFields[field];
const update = (
patch: Partial<typeof prefs.contactFields.phone>
) =>
set('contactFields', {
...prefs.contactFields,
[field]: { ...prefs.contactFields[field], ...patch },
});
return (
<div
className="flex items-center justify-between gap-4"
key={field}
>
<Toggle
checked={shown}
label={label}
onChange={(event) =>
update({ shown: event.target.checked })
}
/>
<Toggle
checked={required}
isDisabled={!shown}
label={
<>
Required
<span className="sr-only"> {label}</span>
</>
}
onChange={(event) =>
update({ required: event.target.checked })
}
/>
</div>
);
})}
<div>
<Button iconStart={AddIcon} variant="neutralTertiary">
Add field
</Button>
</div>
</ToggleGroup>
</FormSection>
<Divider />
<FormSection title="Customization">
<SettingText
description="Select standard display preferences for your Booking Page."
label="General"
/>
<SettingRow
description="This will be the default language of your Booking Page."
label="Preferred language"
>
<SelectControl
label="Preferred language"
onChange={bindOption('language')}
options={LANGUAGES}
value={prefs.language}
/>
</SettingRow>
<SettingRow
description="Display time in 12-hour AM/PM or 24-hour format."
label="Time format"
>
<SelectControl
label="Time format"
onChange={bindOption('timeFormat')}
options={TIME_FORMATS}
value={prefs.timeFormat}
/>
</SettingRow>
<SettingRow
description="Set the first day of the week as seen on your Booking Page."
label="Week starts on"
>
<SelectControl
label="Week starts on"
onChange={bindOption('weekStart')}
options={WEEK_STARTS}
value={prefs.weekStart}
/>
</SettingRow>
</FormSection>
</div>
</form>
</AppShell>
);
}
function FormSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-5">
<Heading as="h2" className="text-heading-14">
{title}
</Heading>
{children}
</section>
);
}
function Divider() {
return <div className="border-t border-solid border-tertiary" />;
}
/** An info icon that explains a setting on hover or focus. */
function InfoTip({ content }: { content: string }) {
return (
<Tooltip content={content}>
{({ triggerProps }) => (
<IconButton
{...triggerProps}
aria-label="More info"
icon={InformationIcon}
size="small"
variant="neutralTertiary"
/>
)}
</Tooltip>
);
}
function SettingText({
label,
description,
info,
}: {
label: string;
description: string;
info?: string;
}) {
return (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-1">
<Text className="text-body-14">{label}</Text>
{info ? <InfoTip content={info} /> : null}
</div>
<Text className="text-body-12 text-secondary">{description}</Text>
</div>
);
}
/** Label and description on the start side, the control on the end side. */
function SettingRow({
label,
description,
info,
children,
}: {
label: string;
description: string;
info?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<SettingText description={description} info={info} label={label} />
<div className="flex shrink-0 gap-2">{children}</div>
</div>
);
}
function SelectControl({
label,
options,
value,
onChange,
}: {
label: string;
options: { label: string; value: string }[];
value: string;
onChange: (value: string) => void;
}) {
return (
<Field className="w-[128px]" label={label} labelVisibility="hidden">
<SelectMenu popoverMatchReferenceWidth>
<SelectMenuTrigger>
{options.find((option) => option.value === value)?.label}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={options}>
{(option) => (
<SelectMenuItem
id={option.value}
isSelected={option.value === value}
onClick={() => onChange(option.value)}
>
{option.label}
</SelectMenuItem>
)}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
}
/** A number paired with its unit, e.g. `15` `Minutes`. */
function DurationInput({
label,
amount,
unit,
units,
onAmountChange,
onUnitChange,
}: {
label: string;
amount: string;
unit: string;
units: { label: string; value: string }[];
onAmountChange: React.ChangeEventHandler<HTMLInputElement>;
onUnitChange: (value: string) => void;
}) {
return (
<>
<Field
className="w-[52px]"
label={`${label} amount`}
labelVisibility="hidden"
>
<TextInput
inputMode="numeric"
onChange={onAmountChange}
value={amount}
/>
</Field>
<SelectControl
label={`${label} unit`}
onChange={onUnitChange}
options={units}
value={unit}
/>
</>
);
}
function ToggleGroup({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<fieldset className="flex flex-col gap-4">
<legend className="mb-5 flex flex-col gap-1">
<Text className="text-body-14 font-stronger">{title}</Text>
<Text className="text-body-12 text-secondary">{description}</Text>
</legend>
{children}
</fieldset>
);
}
/**
* A toggle with an info tip after its label, kept outside the `<label>`. A
* hint goes under the whole row, not indented like a toggle description.
*/
function ToggleWithInfo({
info,
hint,
...toggleProps
}: React.ComponentProps<typeof Toggle> & { info: string; hint?: string }) {
const hintId = React.useId();
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Toggle {...toggleProps} aria-describedby={hint ? hintId : undefined} />
<InfoTip content={info} />
</div>
{hint ? (
<Text className="text-body-12 text-secondary" id={hintId}>
{hint}
</Text>
) : null}
</div>
);
}
function NavSidebar({
activeNav,
isCollapsed,
onNavigate,
onToggleCollapsed,
}: {
activeNav: string;
isCollapsed: boolean;
onNavigate: (label: string) => void;
onToggleCollapsed: () => void;
}) {
const navItem = (
label: string,
Icon: (typeof NAV_ITEMS)[number]['icon'],
isActive = false
) => {
const item = (triggerProps = {}) => (
<MenuItem
{...triggerProps}
aria-current={isActive ? 'page' : undefined}
aria-label={isCollapsed ? label : undefined}
as="a"
className={
isCollapsed
? 'justify-center origin-left overflow-hidden whitespace-nowrap rounded-8px px-2 text-body-12'
: 'origin-left overflow-hidden whitespace-nowrap rounded-8px text-body-12'
}
classNames={isCollapsed ? { center: 'hidden' } : undefined}
href={`#${label.toLowerCase()}`}
isHighlighted={isActive}
key={label}
onClick={() => onNavigate(label)}
railStart={<Icon size="16" />}
size="large"
>
{label}
</MenuItem>
);
// Collapsed, only the icon shows: the tooltip names it.
return isCollapsed ? (
<Tooltip content={label} key={label} placement="right">
{({ triggerProps }) => item(triggerProps)}
</Tooltip>
) : (
item()
);
};
// Collapsed, the rail's inner width is 32px: padding sizes each row to it.
return (
<aside
className={`fixed left-0 top-0 z-[2] flex h-screen origin-left flex-col items-start gap-6 border-r border-solid border-tertiary bg-neutral pb-4 pe-3 ps-3 pt-4 transition-[width] duration-200 ease-in-out ${
isCollapsed ? 'w-[57px]' : 'w-[216px]'
}`}
>
{/* Collapsed, the header is only the menu button that expands it. */}
<div
className={`flex w-full items-center gap-2 overflow-hidden whitespace-nowrap ${
isCollapsed ? 'justify-center' : 'px-2'
}`}
>
{isCollapsed ? (
<IconButton
aria-label="Expand sidebar"
icon={MenuIcon}
onClick={onToggleCollapsed}
variant="neutralTertiary"
/>
) : (
<>
<SetmoreIcon className="shrink-0" size="24" />
<Text className="where:block text-primary text-body-12 font-stronger line-clamp-2 w-[110px] flex-shrink-0 !leading-4">
AnywhereWorks
</Text>
<IconButton
aria-label="Collapse sidebar"
icon={ChevronLeftIcon}
onClick={onToggleCollapsed}
variant="neutralTertiary"
/>
</>
)}
</div>
<nav aria-label="Primary" className="flex w-full flex-col gap-1">
{navItem('Grow your brand', GrowYourBrandIcon)}
<div className="my-2" />
{NAV_ITEMS.map((item) =>
navItem(item.label, item.icon, item.label === activeNav)
)}
</nav>
<div className="mt-auto flex w-full flex-col gap-1">
<MenuItem
aria-label={isCollapsed ? 'Alex Morgan' : undefined}
className={
isCollapsed
? 'justify-start overflow-hidden whitespace-nowrap rounded-8px px-1 text-body-12'
: ' overflow-hidden justify-start whitespace-nowrap rounded-8px text-body-12'
}
classNames={{ center: isCollapsed ? 'w-0' : 'w-full ' }}
railStart={<Avatar name="Alex Morgan" size="24" status="available" />}
size="large"
>
{isCollapsed ? null : 'Alex Morgan'}
</MenuItem>
</div>
</aside>
);
}