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-form

Code

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