Forms
8 blocksForm 01
Preview
npx nateui@latest add ProjectCreateFormDark
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import DatePicker from '@/components/ui/DatePicker'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import IconFrame from '@/components/composites/IconFrame'
import { PiCloudArrowUp, PiFolderSimple } from 'react-icons/pi'
const categories = [
{ label: 'Client work', value: 'client-work' },
{ label: 'Internal tooling', value: 'internal-tooling' },
{ label: 'Research', value: 'research' },
{ label: 'Maintenance', value: 'maintenance' },
]
const requiredMark = <span className="text-destructive">*</span>
export default function ProjectCreateForm() {
return (
<Form
className="max-w-[620px] mx-auto"
aria-labelledby="create-project-title"
>
<Card
className="w-full"
bodyClass="px-4 pt-4 pb-0"
header={{
className: 'p-4',
content: (
<div className="flex items-center gap-4">
<IconFrame
variant="layered"
size={36}
className="shrink-0"
>
<PiFolderSimple
aria-hidden="true"
className="text-2xl"
/>
</IconFrame>
<div className="min-w-0">
<h5
className="text-lg font-semibold text-card-foreground"
>
Create project
</h5>
<p className="text-muted-foreground">
Description and documents can be added
later.
</p>
</div>
</div>
),
}}
footer={{
className:
'flex flex-wrap items-center justify-between gap-2 p-4',
content: (
<>
<Button type="button">Cancel</Button>
<div className="flex items-center gap-2">
<Button type="button">Save draft</Button>
<Button type="button" variant="solid">
Create
</Button>
</div>
</>
),
}}
>
<div className="@container">
<div className="grid grid-cols-1 gap-x-4 @lg:grid-cols-2">
<Form.Field
label="Project name"
extra={requiredMark}
htmlFor="project-name"
>
<Input id="project-name" placeholder="Project 01" />
</Form.Field>
<Form.Field
label="Category"
extra={requiredMark}
labelId="project-category-label"
>
<Select
inputId="project-category-label"
placeholder="Select a category"
options={categories}
/>
</Form.Field>
<Form.Field label="Start date" extra={requiredMark}>
<DatePicker placeholder="Select a date" />
</Form.Field>
<Form.Field label="End date" extra={requiredMark}>
<DatePicker placeholder="Select a date" />
</Form.Field>
<Form.Field
label="Description"
htmlFor="project-description"
className="@lg:col-span-2"
>
<Input
id="project-description"
textArea
rows={4}
placeholder="What does done look like for this project?"
/>
</Form.Field>
<Form.Field
label="Documents"
className="@lg:col-span-2"
>
<p className="mb-4 text-muted-foreground">
Briefs, specs, or anything the team should have
on hand.
</p>
<Upload
draggable
multiple
accept="application/pdf,image/png,image/jpeg"
className="w-full"
aria-label="Upload project documents"
>
<div className="flex flex-col items-center gap-4 p-8 text-center">
<PiCloudArrowUp
aria-hidden="true"
className="text-4xl text-muted-foreground"
/>
<div className="min-w-0">
<p className="font-medium text-card-foreground">
Drop files here or{' '}
<span className="text-primary">
browse
</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">
PDF or image, up to 25 MB each
</p>
</div>
</div>
</Upload>
</Form.Field>
</div>
</div>
</Card>
</Form>
)
}
Form 02
Preview
npx nateui@latest add PaymentCheckoutFormDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import { PiCreditCard, PiLockSimple } from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const methods = [
{ value: 'card', label: 'Card', logo: 'creditCard.png' },
{ value: 'apple-pay', label: 'Apple Pay', logo: 'applePay.png' },
{ value: 'google-pay', label: 'Google Pay', logo: 'googlePay.png' },
{ value: 'paypal', label: 'PayPal', logo: 'paypal.png' },
]
const countries = [
{ value: 'US', label: 'United States' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'CA', label: 'Canada' },
{ value: 'AU', label: 'Australia' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'JP', label: 'Japan' },
{ value: 'SG', label: 'Singapore' },
]
const countryFlag = (code: string) => (
<img
src={`${assetBase}/countries/${code}.png`}
alt=""
className="h-4 w-4 shrink-0"
/>
)
const states = [
{ value: 'or', label: 'Oregon' },
{ value: 'wa', label: 'Washington' },
{ value: 'nv', label: 'Nevada' },
]
const amount = '$248.00'
const joinedGroup = [
'mb-8 flex flex-col',
'[&_.input]:relative [&_.select]:relative',
'[&_.input:focus]:z-20 [&_.input:focus-within]:z-20',
'[&_.select.select-menu-open]:z-30',
].join(' ')
export default function PaymentCheckoutForm() {
const [method, setMethod] = useState('card')
return (
<Form className="max-w-[620px] mx-auto" aria-labelledby="checkout-title">
<div className="w-full">
<div className="@container">
<h5
id="checkout-title"
className="text-lg font-semibold text-card-foreground"
>
Payment details
</h5>
<fieldset className="mt-4">
<legend className="sr-only">Payment method</legend>
<div className="grid grid-cols-2 gap-2 @md:grid-cols-4">
{methods.map((item) => {
const selected = method === item.value
return (
<label
key={item.value}
className="cursor-pointer rounded-card has-[:focus-visible]:ring has-[:focus-visible]:ring"
>
<input
type="radio"
name="payment-method"
value={item.value}
checked={selected}
onChange={() =>
setMethod(item.value)
}
className="sr-only"
/>
<Card
className={
selected
? 'border-primary ring'
: 'hover:bg-accent'
}
bodyClass="flex flex-col items-start gap-4 p-2"
>
<span className="flex h-12 w-12 items-center justify-center rounded-control-sm">
<img
src={`${assetBase}/thumbs/payment/${item.logo}`}
alt=""
className="block h-full w-full object-contain"
/>
</span>
<span className="text-xs font-medium">
{item.label}
</span>
</Card>
</label>
)
})}
</div>
</fieldset>
<div className="mt-4">
<Form.Field
label="Email address"
htmlFor="checkout-email"
>
<Input
id="checkout-email"
type="email"
defaultValue="user@example.com"
/>
</Form.Field>
<Form.Field
label="Card number"
htmlFor="checkout-card-number"
>
<Input
id="checkout-card-number"
inputMode="numeric"
defaultValue="1234 5678 9012 3456"
/>
</Form.Field>
<div className="grid grid-cols-1 gap-x-4 @lg:grid-cols-2">
<Form.Field
label="Expiration date"
htmlFor="checkout-expiry"
>
<Input
id="checkout-expiry"
inputMode="numeric"
defaultValue="04 / 2027"
/>
</Form.Field>
<Form.Field
label="Security code"
htmlFor="checkout-security-code"
>
<Input
id="checkout-security-code"
inputMode="numeric"
defaultValue="123"
suffix={
<PiCreditCard
aria-hidden="true"
className="text-lg text-muted-foreground"
/>
}
/>
</Form.Field>
</div>
<Form.Field
label="Cardholder name"
htmlFor="checkout-cardholder"
>
<Input
id="checkout-cardholder"
defaultValue="Avery Lin"
/>
</Form.Field>
<span id="checkout-country-label" className="sr-only">
Country
</span>
<span id="checkout-state-label" className="sr-only">
State
</span>
<div className={joinedGroup}>
<Select
inputId="checkout-country-label"
className="rounded-b-none"
options={countries}
defaultValue={countries[0]}
customInputDisplay={(selected) => (
<Select.ValueWithPrefix
label={selected?.label}
prefix={countryFlag(
selected?.value ?? 'US',
)}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
label={option.label}
prefix={countryFlag(option.value)}
selected={selected}
checkIcon={CheckIcon}
/>
)}
/>
<Input
aria-label="Street address"
className="-mt-px rounded-none"
defaultValue="18 Harbour Street"
suffix={
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs px-2 !h-6 -me-2"
>
Clear
</Button>
}
/>
<Select
inputId="checkout-state-label"
className="-mt-px rounded-none"
options={states}
defaultValue={states[0]}
/>
<div className="-mt-px flex">
<Input
aria-label="City"
className="min-w-0 rounded-t-none rounded-br-none"
defaultValue="Portland"
/>
<Input
aria-label="Postal code"
className="-ml-px min-w-0 rounded-t-none rounded-bl-none"
inputMode="numeric"
defaultValue="10001"
/>
</div>
</div>
<Form.Field
label="Tax ID number (optional)"
htmlFor="checkout-tax-id"
>
<Input
id="checkout-tax-id"
defaultValue="824619305"
/>
</Form.Field>
</div>
<dl className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{amount}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="font-semibold">Total</dt>
<dd className="font-semibold tabular-nums">
{amount}
</dd>
</div>
</dl>
<Button
type="button"
block
variant="solid"
className="mt-8"
icon={<PiLockSimple />}
iconAlignment="end"
>
Pay {amount}
</Button>
</div>
</div>
</Form>
)
}
Form 03
Preview
npx nateui@latest add BucketCreateFormDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import InputGroup from '@/components/ui/InputGroup'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Tag from '@/components/ui/Tag'
import classNames from '@/utils/classNames'
import { PiArrowRight, PiCloud, PiInfinity } from 'react-icons/pi'
const organizations = [
{ value: 'acme', label: 'acme' },
{ value: 'core-platform', label: 'core-platform' },
{ value: 'data-eng', label: 'data-eng' },
]
const regions = [
{ value: 'eu-west-1', label: 'eu-west-1 Europe (Ireland)' },
{ value: 'us-east-2', label: 'us-east-2 US East (Ohio)' },
{ value: 'ap-southeast-1', label: 'ap-southeast-1 Asia Pacific (Singapore)' },
]
const activeRegion = 'eu-west-1'
const plans = [
{
value: 'business',
name: 'Business',
price: '$40',
period: 'per month',
description: 'Pick your own capacity and throughput.',
allowance: 'Unlimited',
allowanceRest: 'storage and transfer',
unlimited: true,
free: false,
},
{
value: 'standard',
name: 'Standard',
price: '$20',
period: 'per month',
description: 'Billed on storage and transfer, for steady traffic.',
allowance: '5 TB',
allowanceRest: 'storage and 50 TB transfer per month',
unlimited: false,
free: false,
},
{
value: 'starter',
name: 'Starter',
price: 'Free',
period: '',
description: 'Billed on storage and transfer, at no cost on this tier.',
allowance: '50 GB',
allowanceRest: 'storage and 500 GB transfer per month',
unlimited: false,
free: true,
},
]
export default function BucketCreateForm() {
const [plan, setPlan] = useState('standard')
return (
<Form
className="mx-auto max-w-2xl"
aria-labelledby="create-bucket-title"
>
<div>
<div className="@container">
<h5
id="create-bucket-title"
className="text-lg font-semibold text-card-foreground"
>
Create bucket
</h5>
<div className="mt-4 flex items-end gap-2">
<Form.Field
label="Organization"
labelId="bucket-organization-label"
className="w-40 shrink-0"
>
<Select
inputId="bucket-organization-label"
options={organizations}
defaultValue={organizations[0]}
/>
</Form.Field>
<span
aria-hidden="true"
className="mb-7 flex h-control-md items-center text-muted-foreground"
>
/
</span>
<Form.Field
label="Bucket name"
htmlFor="bucket-name"
className="min-w-0 flex-1"
>
<Input id="bucket-name" placeholder="media-assets" />
</Form.Field>
</div>
<div className="mb-8">
<span id="bucket-region-label" className="sr-only">
Region
</span>
<InputGroup className="w-full">
<InputGroup.Addon>Region</InputGroup.Addon>
<Select
className="flex-1"
inputId="bucket-region-label"
options={regions}
defaultValue={regions[0]}
/>
</InputGroup>
</div>
<p className="font-medium text-card-foreground">Plan type</p>
<Radio.Group
vertical
className="mt-2 flex w-full gap-2"
value={plan}
onChange={(value) => setPlan(value as string)}
>
{plans.map((item) => (
<Radio
value={item.value}
key={item.value}
className={
classNames(
'p-4 rounded-card items-start border',
plan === item.value
? 'border-primary ring'
: 'hover:bg-accent'
)
}
>
<span className="flex items-start justify-between gap-4 -mt-1">
<span className="flex min-w-0 flex-col gap-2">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-card-foreground">
{item.name}
</span>
<Tag
prefix={
<PiCloud aria-hidden="true" />
}
className="gap-1 bg-card"
>
{activeRegion}
</Tag>
</span>
<span className="flex min-w-0 flex-col gap-1">
<span className="font-normal">
{item.description}
</span>
<span className="flex flex-wrap items-center gap-1 text-xs">
{item.unlimited && (
<PiInfinity
aria-hidden="true"
className="text-base text-success"
/>
)}
<span
className={
item.unlimited
? 'font-semibold text-success'
: 'font-semibold text-card-foreground tabular-nums'
}
>
{item.allowance}
</span>
<span className="font-normal text-muted-foreground">
{item.allowanceRest}
</span>
</span>
</span>
</span>
<span className="shrink-0 text-end">
<span
className={
classNames(
'text-lg',
item.free
? 'font-semibold text-card-foreground text-base'
: 'font-semibold tabular-nums'
)
}
>
{item.price}
</span>
{item.period && (
<span className="block text-xs text-muted-foreground">
{item.period}
</span>
)}
</span>
</span>
</Radio>
))}
</Radio.Group>
<div className="mt-8 flex items-center justify-end gap-2">
<Button type="button">Cancel</Button>
<Button
type="button"
variant="solid"
icon={<PiArrowRight />}
iconAlignment="end"
>
Next step
</Button>
</div>
</div>
</div>
</Form>
)
}
Form 04
Preview
npx nateui@latest add CryptoBuySellFormDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Segment from '@/components/ui/Segment'
import Select from '@/components/ui/Select'
type FiatOption = {
value: string
label: string
symbol: string
exchangeRate: number
colorClassName: string
}
type CryptoOption = {
value: string
label: string
logo: string
price: number
}
const assetBase = 'https://statics.nateui.com/img'
const fiatOptions: FiatOption[] = [
{
value: 'usd',
label: 'USD',
symbol: '$',
exchangeRate: 1,
colorClassName: 'bg-palette-emerald',
},
{
value: 'eur',
label: 'EUR',
symbol: '€',
exchangeRate: 0.85,
colorClassName: 'bg-palette-blue',
},
{
value: 'gbp',
label: 'GBP',
symbol: '£',
exchangeRate: 0.73,
colorClassName: 'bg-palette-purple',
},
{
value: 'jpy',
label: 'JPY',
symbol: '¥',
exchangeRate: 110,
colorClassName: 'bg-palette-rose',
},
]
const cryptoOptions: CryptoOption[] = [
{ value: 'btc', label: 'BTC', logo: 'btc.png', price: 97234.56 },
{ value: 'eth', label: 'ETH', logo: 'eth.png', price: 3456.78 },
{ value: 'sol', label: 'SOL', logo: 'sol.png', price: 189.45 },
{ value: 'xrp', label: 'XRP', logo: 'xrp.png', price: 2.18 },
{ value: 'ada', label: 'ADA', logo: 'ada.png', price: 0.8934 },
]
const paymentOptions = [
{ value: 'card', label: 'Debit/Credit Card' },
]
const FiatMark = ({
symbol,
colorClassName,
}: Pick<FiatOption, 'symbol' | 'colorClassName'>) => (
<span
aria-hidden="true"
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-primary-foreground ${colorClassName}`}
>
{symbol}
</span>
)
const CryptoMark = ({ logo }: Pick<CryptoOption, 'logo'>) => (
<img
src={`${assetBase}/thumbs/crypto/${logo}`}
alt=""
className="h-5 w-5 shrink-0 object-contain"
/>
)
const PaymentMark = () => (
<img
src={`${assetBase}/thumbs/payment/creditCard.png`}
alt=""
className="h-6 w-10 shrink-0 object-contain"
/>
)
export default function CryptoBuySellForm() {
const [mode, setMode] = useState<'buy' | 'sell'>('buy')
const [sourceAmount, setSourceAmount] = useState('')
const [targetAmount, setTargetAmount] = useState('')
const [fiat, setFiat] = useState<FiatOption>(fiatOptions[0])
const [crypto, setCrypto] = useState<CryptoOption>(cryptoOptions[0])
const isBuy = mode === 'buy'
const sourceLabel = isBuy ? 'Spend' : 'Sell'
const targetLabel = isBuy ? 'Receive' : 'Get'
const paymentLabel = isBuy ? 'Pay with' : 'Sell to'
const estimatedPrice = (crypto.price / fiat.exchangeRate).toLocaleString(
'en-US',
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)
const handleModeChange = (value: string | string[]) => {
const nextMode = value as 'buy' | 'sell'
setMode(nextMode)
setSourceAmount('')
setTargetAmount('')
}
return (
<Form
className="@container mx-auto w-full max-w-sm"
aria-labelledby="crypto-buy-sell-title"
>
<h5
id="crypto-buy-sell-title"
className="text-lg font-semibold text-card-foreground"
>
{isBuy ? 'Buy' : 'Sell'} Crypto
</h5>
<Segment
value={mode}
onChange={handleModeChange}
className="mt-4 grid w-full"
aria-label="Transaction type"
>
<Segment.Item value="buy" type="button">
Buy
</Segment.Item>
<Segment.Item value="sell" type="button">
Sell
</Segment.Item>
</Segment>
<div className="mt-4 space-y-4">
<div className="rounded-card border p-4 focus-within:ring focus-within:ring-ring">
<label
htmlFor="crypto-source-amount"
className="font-medium text-foreground"
>
{sourceLabel}
</label>
<div className="mt-2 flex items-center gap-4">
<Input
id="crypto-source-amount"
type="text"
inputMode="decimal"
value={sourceAmount}
onChange={(event) =>
setSourceAmount(event.target.value)
}
placeholder="0"
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-xl font-semibold focus:border-0 focus:ring-0"
/>
<span id="crypto-fiat-label" className="sr-only">
Spending currency
</span>
<Select
className="w-36 shrink-0"
inputId="crypto-fiat-label"
options={fiatOptions}
value={fiat}
onChange={setFiat}
customInputDisplay={(selected) => (
<Select.ValueWithPrefix
label={selected?.label}
prefix={
selected ? (
<FiatMark
symbol={selected.symbol}
colorClassName={selected.colorClassName}
/>
) : null
}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
label={option.label}
prefix={
<FiatMark
symbol={option.symbol}
colorClassName={option.colorClassName}
/>
}
selected={selected}
checkIcon={CheckIcon}
/>
)}
/>
</div>
</div>
<div className="rounded-card border p-4 focus-within:ring focus-within:ring-ring">
<label
htmlFor="crypto-target-amount"
className="font-medium text-foreground"
>
{targetLabel}
</label>
<div className="mt-2 flex items-center gap-4">
<Input
id="crypto-target-amount"
type="text"
inputMode="decimal"
value={targetAmount}
onChange={(event) =>
setTargetAmount(event.target.value)
}
placeholder="0"
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-xl font-semibold focus:border-0 focus:ring-0"
/>
<span id="crypto-asset-label" className="sr-only">
Asset to trade
</span>
<Select
className="w-36 shrink-0"
inputId="crypto-asset-label"
options={cryptoOptions}
value={crypto}
onChange={setCrypto}
customInputDisplay={(selected) => (
<Select.ValueWithPrefix
label={selected?.label}
prefix={
selected ? (
<CryptoMark logo={selected.logo} />
) : null
}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
label={option.label}
prefix={<CryptoMark logo={option.logo} />}
selected={selected}
checkIcon={CheckIcon}
/>
)}
/>
</div>
</div>
<div>
<span id="crypto-payment-label" className="mb-2 block font-medium text-foreground">
{paymentLabel}
</span>
<Select
size="lg"
inputId="crypto-payment-label"
options={paymentOptions}
defaultValue={paymentOptions[0]}
customInputDisplay={(selected) => (
<Select.ValueWithPrefix
label={selected?.label}
prefix={<PaymentMark />}
/>
)}
/>
</div>
<p className="text-muted-foreground">
Estimated price:{' '}
<span className="font-medium text-foreground">
1 {crypto.label} ≈ {fiat.symbol}
{estimatedPrice}
</span>
</p>
<Button type="button" block variant="solid">
{isBuy ? 'Buy' : 'Sell'} {crypto.label}
</Button>
</div>
</Form>
)
}
Form 05
Preview
npx nateui@latest add ProductVariantFormDark
import { useEffect, useMemo, useRef, useState } from 'react'
import Button from '@/components/ui/Button'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import InputGroup from '@/components/ui/InputGroup'
import MultiValueInput from '@/components/ui/MultiValueInput'
import Table from '@/components/ui/Table'
import Upload from '@/components/ui/Upload'
import Divider from '@/components/composites/Divider'
import EmptyState from '@/components/composites/EmptyState'
import FormatInput from '@/components/composites/FormatInput'
import IconFrame from '@/components/composites/IconFrame'
import {
PiImageSquare,
PiPlus,
PiSquaresFour,
PiTrash,
} from 'react-icons/pi'
type VariantOption = {
id: number
name: string
values: string[]
}
type VariantInventory = {
price: number
stock: number
sku: string
imageName?: string
imagePreviewUrl?: string
}
const initialOptions: VariantOption[] = [
{
id: 1,
name: 'Finish',
values: ['Natural', 'Walnut'],
},
{
id: 2,
name: 'Width',
values: ['120 cm', '150 cm'],
},
]
const initialInventory: Record<string, VariantInventory> = {
'Natural / 120 cm': {
price: 129,
stock: 18,
sku: 'DSK-NAT-120',
},
'Natural / 150 cm': {
price: 149,
stock: 12,
sku: 'DSK-NAT-150',
},
'Walnut / 120 cm': {
price: 139,
stock: 8,
sku: 'DSK-WAL-120',
},
'Walnut / 150 cm': {
price: 159,
stock: 5,
sku: 'DSK-WAL-150',
},
}
const createCombinations = (options: VariantOption[]) => {
const validOptions = options.filter(
(option) =>
option.name.trim() &&
option.values.some((value) => value.trim()),
)
if (validOptions.length === 0) {
return []
}
const valueGroups = validOptions.map((option) =>
option.values.filter((value) => value.trim()),
)
return valueGroups
.reduce<string[][]>(
(combinations, values) =>
combinations.flatMap((combination) =>
values.map((value) => [...combination, value]),
),
[[]],
)
.map((combination) => combination.join(' / '))
}
export default function ProductVariantForm() {
const nextOptionId = useRef(3)
const variantImageUrls = useRef<Record<string, string>>({})
const [variantOptions, setVariantOptions] =
useState<VariantOption[]>(initialOptions)
const [inventory, setInventory] =
useState<Record<string, VariantInventory>>(initialInventory)
const combinations = useMemo(
() => createCombinations(variantOptions),
[variantOptions],
)
useEffect(() => {
const activeElement = document.activeElement
const formElement = document
.getElementById('product-variants-title')
?.closest('form')
if (
activeElement instanceof HTMLElement &&
formElement?.contains(activeElement)
) {
activeElement.blur()
}
return () => {
Object.values(variantImageUrls.current).forEach((previewUrl) =>
URL.revokeObjectURL(previewUrl),
)
}
}, [])
const updateOption = (
optionId: number,
patch: Partial<Omit<VariantOption, 'id'>>,
) => {
setVariantOptions((options) =>
options.map((option) =>
option.id === optionId ? { ...option, ...patch } : option,
),
)
}
const addOption = () => {
const optionId = nextOptionId.current
nextOptionId.current += 1
setVariantOptions((options) => [
...options,
{
id: optionId,
name: '',
values: [],
},
])
}
const removeOption = (optionId: number) => {
if (variantOptions.length === 1) {
return
}
setVariantOptions((options) =>
options.filter((option) => option.id !== optionId),
)
}
const getInventory = (combination: string): VariantInventory =>
inventory[combination] ?? {
price: 0,
stock: 0,
sku: '',
}
const updateInventory = (
combination: string,
patch: Partial<VariantInventory>,
) => {
setInventory((currentInventory) => ({
...currentInventory,
[combination]: {
...getInventory(combination),
...patch,
},
}))
}
const updateVariantImage = (combination: string, file: File) => {
const previousPreviewUrl = variantImageUrls.current[combination]
if (previousPreviewUrl) {
URL.revokeObjectURL(previousPreviewUrl)
}
const imagePreviewUrl = URL.createObjectURL(file)
variantImageUrls.current[combination] = imagePreviewUrl
updateInventory(combination, {
imageName: file.name,
imagePreviewUrl,
})
}
return (
<Form
className="@container mx-auto min-h-140 w-full max-w-5xl p-6 @4xl:p-8"
size="sm"
aria-labelledby="product-variants-title"
onSubmit={(event) => event.preventDefault()}
>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h4
id="product-variants-title"
className="text-xl font-semibold text-card-foreground"
>
Product variants
</h4>
<p className="mt-1 text-sm text-muted-foreground">
Define the options available for this product.
</p>
</div>
<Button
type="button"
variant="ghost"
icon={<PiPlus />}
onClick={addOption}
>
Add variant
</Button>
</div>
<section className="mt-8" aria-labelledby="variant-options-title">
<h5
id="variant-options-title"
className="mb-2 text-sm font-medium"
>
Variant options
</h5>
<div className="space-y-3">
{variantOptions.map((option, index) => (
<div
key={option.id}
className="flex items-center gap-2"
>
<InputGroup className="min-w-0 flex-1">
<Input
value={option.name}
placeholder="Option name"
aria-label={`Variant option ${index + 1} name`}
className="w-32 shrink-0 @lg:w-44"
onChange={(event) =>
updateOption(option.id, {
name: event.target.value,
})
}
/>
<MultiValueInput
value={option.values}
placeholder="Add values and press enter"
aria-label={`${option.name || `Variant option ${index + 1}`} values`}
className="min-w-0 flex-1"
onChange={(values) =>
updateOption(option.id, { values })
}
/>
</InputGroup>
<Button
type="button"
size="sm"
icon={<PiTrash className="text-sm" />}
aria-label={`Remove variant option ${index + 1}`}
disabled={variantOptions.length === 1}
onClick={() => removeOption(option.id)}
/>
</div>
))}
</div>
</section>
<Divider className="my-6" />
<section aria-labelledby="variant-combinations-title">
<h5 id="variant-combinations-title" className="sr-only">
Generated variant combinations
</h5>
{combinations.length > 0 ? (
<Table
compact
hoverable={false}
className="min-w-192"
overflowClass="rounded-card"
>
<Table.THead>
<Table.Tr>
<Table.Th className="w-52 text-sm normal-case tracking-normal text-foreground">
Variant
</Table.Th>
<Table.Th className="w-64 text-sm normal-case tracking-normal text-foreground">
Price
</Table.Th>
<Table.Th className="w-52 text-sm normal-case tracking-normal text-foreground">
Stock
</Table.Th>
<Table.Th className="w-72 text-sm normal-case tracking-normal text-foreground">
SKU
</Table.Th>
</Table.Tr>
</Table.THead>
<Table.TBody>
{combinations.map((combination) => {
const item = getInventory(combination)
return (
<Table.Tr key={combination}>
<Table.Td>
<div className="flex items-center gap-3 whitespace-nowrap font-medium">
<Upload
showList={false}
uploadLimit={1}
accept="image/png,image/jpeg,image/webp"
onChange={(file) =>
updateVariantImage(
combination,
file,
)
}
>
<Button
type="button"
size="sm"
className="h-10! w-10! overflow-hidden border-dashed p-0"
aria-label={`${item.imagePreviewUrl ? 'Replace' : 'Upload'} image for ${combination}`}
title={
item.imageName ??
`Upload image for ${combination}`
}
icon={
item.imagePreviewUrl ? (
<img
src={
item.imagePreviewUrl
}
alt=""
className="h-full w-full object-cover"
/>
) : (
<PiImageSquare />
)
}
/>
</Upload>
<span>{combination}</span>
</div>
</Table.Td>
<Table.Td>
<FormatInput.Numeric
thousandSeparator
decimalScale={2}
inputPrefix={<span>$</span>}
value={item.price}
aria-label={`${combination} price`}
onValueChange={({ floatValue }) =>
updateInventory(combination, {
price: floatValue ?? 0,
})
}
/>
</Table.Td>
<Table.Td>
<FormatInput.Numeric
decimalScale={0}
allowNegative={false}
value={item.stock}
aria-label={`${combination} stock`}
onValueChange={({ floatValue }) =>
updateInventory(combination, {
stock: floatValue ?? 0,
})
}
/>
</Table.Td>
<Table.Td>
<Input
value={item.sku}
placeholder="Enter SKU"
aria-label={`${combination} SKU`}
onChange={(event) =>
updateInventory(combination, {
sku: event.target.value,
})
}
/>
</Table.Td>
</Table.Tr>
)
})}
</Table.TBody>
</Table>
) : (
<div className="flex min-h-56 items-center justify-center py-4">
<EmptyState
variant="grid"
size={200}
offset={-36}
illustration={
<IconFrame
size={48}
className="text-muted-foreground"
>
<PiSquaresFour className="text-2xl" />
</IconFrame>
}
>
<div className="max-w-xs text-center">
<h6 className="text-base font-medium">
No variants yet
</h6>
<p className="mt-1 text-sm text-muted-foreground">
Add an option name and at least one value to
generate variants.
</p>
</div>
</EmptyState>
</div>
)}
</section>
</Form>
)
}
Form 06
Preview
npx nateui@latest add PlanCheckoutFormDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import InputGroup from '@/components/ui/InputGroup'
import Radio from '@/components/ui/Radio'
import Segment from '@/components/ui/Segment'
import Select from '@/components/ui/Select'
import Tag from '@/components/ui/Tag'
import classNames from '@/utils/classNames'
import { PiCheckCircleFill, PiMinus, PiPlus } from 'react-icons/pi'
const tiers = [
{ value: 'starter', name: 'Starter', price: 29, allowance: 10000 },
{ value: 'growth', name: 'Growth', price: 59, allowance: 50000 },
{ value: 'scale', name: 'Scale', price: 99, allowance: 200000 },
{ value: 'pro', name: 'Pro', price: 149, allowance: 1000000 },
]
const includedFeatures = [
'Unlimited sending domains',
'Unlimited templates',
'Full delivery logs',
'Webhook delivery',
'Priority support',
]
const assetBase = 'https://statics.nateui.com/img'
const countries = [
{ value: 'GB', label: 'United Kingdom' },
{ value: 'IE', label: 'Ireland' },
{ value: 'DE', label: 'Germany' },
{ value: 'SG', label: 'Singapore' },
]
const countryFlag = (code: string) => (
<img
src={`${assetBase}/countries/${code}.png`}
alt=""
className="h-4 w-4 shrink-0"
/>
)
const BLOCK_SIZE = 10000
const BLOCK_PRICE = 10
const DEDICATED_IP_PRICE = 39
const REVIEW_PRICE = 1099
const VAT_RATE = 0.2
const count = (value: number) => value.toLocaleString('en-GB')
const gbp = (value: number) =>
`$${value.toLocaleString('en-GB', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
// Both Radio and Checkbox render `label > [control span, content span]`. These
// stretch the content span so a row's trailing price can sit hard right, and
// give the control wrapper a text line's height so it centres on the first line
// rather than hanging above it.
const rowControl = [
'w-full items-start',
'[&>span:first-child]:h-5',
'[&>span:last-child]:min-w-0 [&>span:last-child]:flex-1',
].join(' ')
export default function PlanCheckoutForm() {
const [tier, setTier] = useState('pro')
const [extraSends, setExtraSends] = useState(true)
const [blocks, setBlocks] = useState(1)
const [dedicatedIp, setDedicatedIp] = useState(false)
const [deliverabilityReview, setDeliverabilityReview] = useState(false)
const [promo, setPromo] = useState('')
const selectedTier = tiers.find((item) => item.value === tier) ?? tiers[3]
const subtotal =
selectedTier.price +
(extraSends ? blocks * BLOCK_PRICE : 0) +
(dedicatedIp ? DEDICATED_IP_PRICE : 0) +
(deliverabilityReview ? REVIEW_PRICE : 0)
const vat = subtotal * VAT_RATE
const total = subtotal + vat
return (
<Form className="@container" aria-labelledby="plan-checkout-title">
<div className="border-b p-4">
<h5
id="plan-checkout-title"
className="text-lg font-semibold text-foreground"
>
Choose your plan
</h5>
<p className="text-muted-foreground">
Every tier includes the full sending API. Only the monthly
volume changes.
</p>
</div>
<div className="grid grid-cols-1 @3xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="p-4">
<Radio.Group
vertical
className="grid w-full grid-cols-2 gap-2 @xl:grid-cols-4"
value={tier}
onChange={(value) => setTier(value as string)}
>
{tiers.map((item) => (
<Radio
key={item.value}
value={item.value}
className={
classNames(
'w-full flex-col rounded-card items-start border p-4',
tier === item.value
? 'border-primary ring'
: 'hover:bg-accent'
)
}
>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1">
<span
className={
tier === item.value
? 'font-medium text-card-foreground'
: 'font-medium text-muted-foreground'
}
>
{item.name}
</span>
</span>
<span className="text-xl font-semibold text-card-foreground tabular-nums">
${item.price}
</span>
<span className="text-xs text-muted-foreground">
{count(item.allowance)} emails
</span>
</span>
</Radio>
))}
</Radio.Group>
<Card className="mt-4" bodyClass="p-0">
<div className="divide-y">
<div className="p-4">
<Checkbox
checked
disabled
readOnly
className={rowControl}
>
<span className="flex items-center justify-between gap-4">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-medium text-card-foreground">
Delivery report
</span>
<span className="font-normal text-muted-foreground">
Covers the billing period now
open.
</span>
</span>
<Tag className="shrink-0">Included</Tag>
</span>
</Checkbox>
</div>
<div className="p-4">
<Checkbox
checked={extraSends}
className={rowControl}
onChange={setExtraSends}
>
<span className="flex flex-wrap items-center justify-between gap-4">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-medium text-card-foreground">
Extra sends
</span>
<span className="font-normal text-muted-foreground">
Your plan covers{' '}
{count(selectedTier.allowance)}.
Add more in blocks of{' '}
{count(BLOCK_SIZE)}.
</span>
</span>
<span className="flex shrink-0 items-center gap-4">
<InputGroup size="sm">
<Button
type="button"
size="sm"
icon={<PiMinus />}
aria-label="Remove a block of sends"
disabled={blocks <= 1}
onClick={() =>
setBlocks(
Math.max(
1,
blocks - 1,
),
)
}
/>
<Input
size="sm"
readOnly
className="w-24 text-center tabular-nums"
aria-label="Extra sends"
value={count(
blocks * BLOCK_SIZE,
)}
/>
<Button
type="button"
size="sm"
icon={<PiPlus />}
aria-label="Add a block of sends"
onClick={() =>
setBlocks(blocks + 1)
}
/>
</InputGroup>
<span className="font-medium text-card-foreground tabular-nums">
${blocks * BLOCK_PRICE}
</span>
</span>
</span>
</Checkbox>
</div>
<div className="p-4">
<Checkbox
checked={dedicatedIp}
className={rowControl}
onChange={setDedicatedIp}
>
<span className="flex items-center justify-between gap-4">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-medium text-card-foreground">
Dedicated IP
</span>
<span className="font-normal text-muted-foreground">
A sending address reserved for
your domain alone.
</span>
</span>
<span className="shrink-0 font-medium text-card-foreground tabular-nums">
${DEDICATED_IP_PRICE}
</span>
</span>
</Checkbox>
</div>
<div className="p-4">
<Checkbox
checked={deliverabilityReview}
className={rowControl}
onChange={setDeliverabilityReview}
>
<span className="flex items-center justify-between gap-4">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-medium text-card-foreground">
Deliverability review
</span>
<span className="font-normal text-muted-foreground">
A specialist audits your sending
setup and flags what hurts inbox
placement. Turnaround is four to
six weeks.
</span>
</span>
<span className="shrink-0 font-medium text-card-foreground tabular-nums">
${count(REVIEW_PRICE)}
</span>
</span>
</Checkbox>
</div>
</div>
</Card>
</div>
<div className="border-t p-4 @3xl:border-t-0 @3xl:border-l">
<Segment defaultValue="card" className="w-full">
<Segment.Item value="card" type="button">Credit Card</Segment.Item>
<Segment.Item value="invoice" type="button">Paypal</Segment.Item>
</Segment>
<h6 className="mt-4 text-base font-medium text-foreground">
Plan: {selectedTier.name}
</h6>
<p className="mt-1 text-muted-foreground">Includes:</p>
<ul className="mt-2 flex flex-col gap-2">
{includedFeatures.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<PiCheckCircleFill
aria-hidden="true"
className="mt-0.5 shrink-0 text-lg text-success"
/>
<span className="min-w-0">{feature}</span>
</li>
))}
</ul>
<dl className="mt-8 flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{gbp(subtotal)}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
VAT ({VAT_RATE * 100}%)
</dt>
<dd className="tabular-nums">{gbp(vat)}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="font-semibold">Total</dt>
<dd className="font-semibold tabular-nums">
{gbp(total)}
</dd>
</div>
</dl>
<div className="mt-8">
<Form.Field
label="Billing country"
labelId="plan-billing-country-label"
>
<Select
inputId="plan-billing-country-label"
options={countries}
defaultValue={countries[0]}
customInputDisplay={(selected) => (
<Select.ValueWithPrefix
label={selected?.label}
prefix={countryFlag(
selected?.value ?? 'GB',
)}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
label={option.label}
prefix={countryFlag(option.value)}
selected={selected}
checkIcon={CheckIcon}
/>
)}
/>
</Form.Field>
<Form.Field
label="Promo code"
htmlFor="plan-promo-code"
>
<InputGroup>
<Input
id="plan-promo-code"
placeholder="Enter code"
value={promo}
onChange={(event) =>
setPromo(event.target.value)
}
/>
<Button
type="button"
disabled={promo.trim().length === 0}
>
Apply
</Button>
</InputGroup>
</Form.Field>
</div>
<Button type="button" block variant="solid">
Pay {gbp(total)}
</Button>
<p className="mt-4 text-center text-xs text-muted-foreground">
You will be taken to a secure payment page to finish
checking out. Completing the purchase accepts our{' '}
<a href="#refunds" className="underline">
refund policy
</a>
.
</p>
</div>
</div>
</Form>
)
}
Form 07
Preview
npx nateui@latest add StorefrontSettingsDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import InputGroup from '@/components/ui/InputGroup'
import Upload from '@/components/ui/Upload'
import { PiSealCheckFill , PiImage } from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const defaultLogo = `${assetBase}/logos/logo-collapsed.svg`
const DESCRIPTION_LIMIT = 160
const storefrontLinks = [
{
id: 'instagram',
prefix: 'instagram.com/',
logo: 'instagram.png',
handle: 'harbourgoods',
},
{
id: 'tiktok',
prefix: 'tiktok.com/@',
logo: 'tiktok.png',
handle: 'harbourgoods',
},
{ id: 'x', prefix: 'x.com/', logo: 'x.png', handle: 'harbourgoods' },
{
id: 'facebook',
prefix: 'facebook.com/',
logo: 'facebook.png',
handle: '',
},
{ id: 'whatsapp', prefix: 'wa.me/', logo: 'whatsapp.png', handle: '' },
]
const settingsRow =
'grid grid-cols-1 gap-4 border-b px-4 py-8 @2xl:grid-cols-[minmax(0,320px)_minmax(0,720px)] @2xl:gap-8'
export default function StorefrontSettings() {
const [description, setDescription] = useState(
'Hand-finished kitchenware, made in small batches and shipped worldwide.',
)
const [onReceipts, setOnReceipts] = useState(true)
const [onPackingSlips, setOnPackingSlips] = useState(true)
const [handles, setHandles] = useState(() =>
Object.fromEntries(
storefrontLinks.map((item) => [item.id, item.handle]),
),
)
const [logo, setLogo] = useState<string | null>(defaultLogo)
const remaining = DESCRIPTION_LIMIT - description.length
// Swap the preview to whatever was just picked, releasing the previous
// object URL so repeated uploads don't leak.
const replaceLogo = (next: string | null) => {
if (logo?.startsWith('blob:')) {
URL.revokeObjectURL(logo)
}
setLogo(next)
}
return (
<Form className="@container" aria-labelledby="storefront-title">
<div className="border-b p-4">
<h5
id="storefront-title"
className="font-semibold text-foreground"
>
Storefront
</h5>
<p className="text-muted-foreground">
Update how your shop appears to customers.
</p>
</div>
<div className={settingsRow}>
<div>
<label
htmlFor="store-name"
className="font-medium text-foreground"
>
Public storefront
</label>
<p className="text-muted-foreground">
Shown to anyone browsing your shop.
</p>
</div>
<div className="flex flex-col gap-2">
<Input id="store-name" defaultValue="Harbour Goods" />
<InputGroup>
<InputGroup.Addon>shop.example/</InputGroup.Addon>
<Input
aria-label="Storefront slug"
defaultValue="harbour-goods"
/>
</InputGroup>
</div>
</div>
<div className={settingsRow}>
<div>
<label
htmlFor="store-description"
className="font-medium text-foreground"
>
Description
</label>
<p className="text-muted-foreground">
A short summary for your shop page.
</p>
</div>
<div>
<Input
id="store-description"
textArea
rows={3}
maxLength={DESCRIPTION_LIMIT}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">
{remaining} characters left
</p>
</div>
</div>
<div className={settingsRow}>
<div>
<p className="font-medium text-foreground">Store logo</p>
<p className="text-muted-foreground">
Replace the mark shown beside your shop name.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{logo ? (
<img
src={logo}
alt="Current store logo"
className="h-12 w-12 shrink-0 rounded-full object-cover"
/>
) : (
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-dashed text-muted-foreground">
<PiImage aria-hidden="true" className="text-lg" />
</span>
)}
<div className="flex items-center gap-2">
<Upload
showList={false}
accept="image/svg+xml,image/png"
aria-label="Upload a store logo"
onChange={(file) =>
replaceLogo(URL.createObjectURL(file))
}
/>
<Button
type="button"
variant="ghost"
disabled={!logo}
onClick={() => replaceLogo(null)}
>
Delete
</Button>
</div>
</div>
</div>
<div className={settingsRow}>
<div>
<p className="font-medium text-foreground">
Logo placement
</p>
<p className="text-muted-foreground">
Add the logo to printed and emailed documents.
</p>
</div>
<div className="flex flex-col gap-4">
<Checkbox
checked={onReceipts}
className="items-start [&>span:first-child]:h-5"
onChange={setOnReceipts}
>
<span className="flex flex-col gap-1">
<span className="font-medium text-foreground">
Order receipts
</span>
<span className="font-normal text-muted-foreground">
Show the logo on receipts emailed to customers.
</span>
</span>
</Checkbox>
<Checkbox
checked={onPackingSlips}
className="items-start [&>span:first-child]:h-5"
onChange={setOnPackingSlips}
>
<span className="flex flex-col gap-1">
<span className="font-medium text-foreground">
Packing slips
</span>
<span className="font-normal text-muted-foreground">
Show the logo on slips included with orders.
</span>
</span>
</Checkbox>
</div>
</div>
<div className={settingsRow}>
<p className="font-medium text-foreground">Storefront links</p>
<div className="flex flex-col gap-2">
{storefrontLinks.map((item) => (
<InputGroup key={item.id}>
<InputGroup.Addon className="gap-2">
<img
src={`${assetBase}/thumbs/brands/${item.logo}`}
alt=""
className="h-4 w-4 shrink-0 object-contain"
/>
{item.prefix}
</InputGroup.Addon>
<Input
aria-label={item.prefix}
className="min-w-0"
placeholder="Enter your link"
value={handles[item.id]}
suffix={
handles[item.id].trim().length > 0 ? (
<PiSealCheckFill
aria-hidden="true"
className="text-lg text-success"
/>
) : undefined
}
onChange={(event) =>
setHandles({
...handles,
[item.id]: event.target.value,
})
}
/>
</InputGroup>
))}
</div>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<Button type="button">Cancel</Button>
<Button type="button" variant="solid">
Save changes
</Button>
</div>
</Form>
)
}
Form 08
Preview
npx nateui@latest add CheckoutWizardFormDark
import { useState } from 'react'
import Alert from '@/components/ui/Alert'
import Button from '@/components/ui/Button'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Steps from '@/components/ui/Steps'
import IconFrame from '@/components/composites/IconFrame'
import {
PiArrowLeft,
PiArrowRight,
PiCheckCircle,
PiCreditCard,
PiReceipt,
PiTruck,
PiUser,
} from 'react-icons/pi'
const steps = [
{
label: 'Contact',
title: 'Contact details',
icon: PiUser,
},
{
label: 'Delivery',
title: 'Delivery address',
icon: PiTruck,
},
{
label: 'Payment',
title: 'Payment method',
icon: PiCreditCard,
},
{
label: 'Review',
title: 'Review order',
icon: PiReceipt,
},
]
export default function CheckoutWizardForm() {
const [currentStep, setCurrentStep] = useState(1)
const [isPlaced, setIsPlaced] = useState(false)
const [details, setDetails] = useState({
firstName: 'Avery',
lastName: 'Lin',
email: 'avery@example.com',
phone: '+1 503 555 0148',
country: 'United States',
region: 'Oregon',
address: '24 North Street',
apartment: 'Suite 4',
postalCode: '97205',
city: 'Portland',
cardNumber: '4242 4242 4242 4242',
cardholderName: 'Avery Lin',
expiration: '04 / 2028',
securityCode: '123',
billingCountry: 'United States',
billingPostalCode: '97205',
})
const isFirstStep = currentStep === 0
const isLastStep = currentStep === steps.length - 1
const activeStep = steps[currentStep]
const ActiveIcon = activeStep.icon
const updateDetail = (field: keyof typeof details, value: string) => {
setDetails((currentDetails) => ({
...currentDetails,
[field]: value,
}))
}
const handlePrevious = () => {
setIsPlaced(false)
setCurrentStep((step) => Math.max(step - 1, 0))
}
const handleNext = () => {
if (isLastStep) {
setIsPlaced(true)
return
}
setCurrentStep((step) => Math.min(step + 1, steps.length - 1))
}
const handleStepChange = (step: number) => {
setIsPlaced(false)
setCurrentStep(step)
}
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<div className="grid grid-cols-1 gap-x-6 @md:grid-cols-2">
<Form.Field
label="First name"
htmlFor="checkout-first-name"
>
<Input
id="checkout-first-name"
value={details.firstName}
onChange={(event) =>
updateDetail(
'firstName',
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
label="Last name"
htmlFor="checkout-last-name"
>
<Input
id="checkout-last-name"
value={details.lastName}
onChange={(event) =>
updateDetail('lastName', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Email address"
htmlFor="checkout-email"
>
<Input
id="checkout-email"
type="email"
value={details.email}
onChange={(event) =>
updateDetail('email', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Phone number"
htmlFor="checkout-phone"
>
<Input
id="checkout-phone"
type="tel"
value={details.phone}
onChange={(event) =>
updateDetail('phone', event.target.value)
}
/>
</Form.Field>
</div>
)
case 1:
return (
<div className="grid grid-cols-1 gap-x-6 @md:grid-cols-2">
<Form.Field
label="Country or region"
htmlFor="checkout-country"
>
<Input
id="checkout-country"
value={details.country}
onChange={(event) =>
updateDetail('country', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="State or province"
htmlFor="checkout-region"
>
<Input
id="checkout-region"
value={details.region}
onChange={(event) =>
updateDetail('region', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Street address"
htmlFor="checkout-address"
>
<Input
id="checkout-address"
value={details.address}
onChange={(event) =>
updateDetail('address', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Apartment (optional)"
htmlFor="checkout-apartment"
>
<Input
id="checkout-apartment"
value={details.apartment}
onChange={(event) =>
updateDetail('apartment', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="City"
htmlFor="checkout-city"
>
<Input
id="checkout-city"
value={details.city}
onChange={(event) =>
updateDetail('city', event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Postal code"
htmlFor="checkout-postal-code"
>
<Input
id="checkout-postal-code"
value={details.postalCode}
onChange={(event) =>
updateDetail(
'postalCode',
event.target.value,
)
}
/>
</Form.Field>
</div>
)
case 2:
return (
<div className="grid grid-cols-1 gap-x-6 @md:grid-cols-2">
<Form.Field
label="Card number"
htmlFor="checkout-card-number"
>
<Input
id="checkout-card-number"
inputMode="numeric"
value={details.cardNumber}
onChange={(event) =>
updateDetail(
'cardNumber',
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
label="Name on card"
htmlFor="checkout-cardholder-name"
>
<Input
id="checkout-cardholder-name"
value={details.cardholderName}
onChange={(event) =>
updateDetail(
'cardholderName',
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
label="Expiration date"
htmlFor="checkout-expiration"
>
<Input
id="checkout-expiration"
inputMode="numeric"
value={details.expiration}
onChange={(event) =>
updateDetail(
'expiration',
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
label="Security code"
htmlFor="checkout-security-code"
>
<Input
id="checkout-security-code"
inputMode="numeric"
value={details.securityCode}
onChange={(event) =>
updateDetail(
'securityCode',
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
label="Billing country"
htmlFor="checkout-billing-country"
>
<Input
id="checkout-billing-country"
value={details.billingCountry}
onChange={(event) =>
updateDetail(
'billingCountry',
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
label="Billing postal code"
htmlFor="checkout-billing-postal-code"
>
<Input
id="checkout-billing-postal-code"
value={details.billingPostalCode}
onChange={(event) =>
updateDetail(
'billingPostalCode',
event.target.value,
)
}
/>
</Form.Field>
</div>
)
default:
return (
<div>
<dl className="space-y-4 border-y py-4">
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
Customer
</dt>
<dd className="text-right font-medium">
{details.firstName} {details.lastName}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
Email
</dt>
<dd className="text-right font-medium">
{details.email}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
Phone
</dt>
<dd className="text-right font-medium">
{details.phone}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
Ship to
</dt>
<dd className="text-right font-medium">
{details.city}, {details.region}
</dd>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-4">
<dt className="text-muted-foreground">
Payment
</dt>
<dd className="font-medium">•••• 4242</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
Subtotal
</dt>
<dd>$84.00</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-muted-foreground">
Delivery
</dt>
<dd>Included</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="font-semibold">Total</dt>
<dd className="font-semibold">$84.00</dd>
</div>
</dl>
{isPlaced ? (
<Alert className="mb-4" showIcon variant="success">
Order placed in this preview.
</Alert>
) : null}
</div>
)
}
}
return (
<Form
className="@container w-full"
aria-labelledby="checkout-wizard-title"
onSubmit={(event) => event.preventDefault()}
>
<div className="grid min-h-96 @4xl:min-h-140 @4xl:grid-cols-6">
<aside className=" @4xl:col-span-2 p-2">
<div className="p-8 rounded-card bg-background border h-full">
<h3
className="text-xl font-semibold text-card-foreground"
>
Checkout
</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Complete this order in four steps.
</p>
<Steps
vertical
current={currentStep}
onChange={handleStepChange}
className="mt-8"
>
{steps.map(({ label }) => (
<Steps.Item key={label} title={label} />
))}
</Steps>
</div>
</aside>
<section className="min-w-0 px-6 py-8 @4xl:col-span-4 @4xl:flex @4xl:flex-col @4xl:px-10 @4xl:py-10">
<div className="flex items-center gap-2">
<IconFrame
variant="layered"
size={32}
className="shrink-0 text-primary"
>
<ActiveIcon
aria-hidden="true"
className="text-lg"
/>
</IconFrame>
<h4 className="text-xl font-semibold text-card-foreground">
{activeStep.title}
</h4>
</div>
<div className="mt-8">{renderStepContent()}</div>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 @4xl:mt-auto">
<Button
type="button"
disabled={isFirstStep}
icon={<PiArrowLeft />}
onClick={handlePrevious}
>
Previous
</Button>
<Button
type="button"
variant="solid"
icon={isLastStep ? <PiCheckCircle /> : <PiArrowRight />}
iconAlignment="end"
disabled={isPlaced}
onClick={handleNext}
>
{isLastStep ? 'Place order' : 'Continue'}
</Button>
</div>
</section>
</div>
</Form>
)
}