Forms

8 blocks

Form 01

Preview
npx nateui@latest add ProjectCreateForm
Dark
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 PaymentCheckoutForm
Dark
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 BucketCreateForm
Dark
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 CryptoBuySellForm
Dark
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 ProductVariantForm
Dark
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 PlanCheckoutForm
Dark
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 StorefrontSettings
Dark
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 CheckoutWizardForm
Dark
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>
    )
}