Auto Reload

Toggle + collapsible threshold/amount form for automatic credit reloading with locale-aware currency.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/auto-reload.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ChangeEvent, type ComponentPropsWithoutRef, type ReactNode, useCallback, useId, useMemo, useState, } from "react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { Input } from "../input/input"; import { Switch } from "../switch/switch"; const CENTS_PER_UNIT = 100; const DEFAULT_LOCALE = "en-US"; const DEFAULT_CURRENCY = "USD"; const DEFAULT_STEP_CENTS = 100; /** * Snapshot passed to {@link AutoReloadProps.onSave}. * * @public */ export type AutoReloadSavePayload = { reloadAmountCents: number; thresholdCents: number; }; /** * Localizable strings. * * @public */ export type AutoReloadLabels = { /** Caption for the disabled banner. */ disabledFallback?: string; /** Caption for the toggle headline. Defaults to `"Auto-reload"`. */ heading?: string; /** Helper line under the toggle headline. Defaults to `"Automatically reload credits when balance is low."`. */ helper?: string; /** Helper line under the reload-amount input. */ reloadHelper?: string; /** Caption for the reload-amount input. Defaults to `"Reload amount"`. */ reloadLabel?: string; /** Caption for the save button. Defaults to `"Save settings"`. */ save?: string; /** Caption for the save button while saving. Defaults to `"Saving…"`. */ saving?: string; /** Helper line under the threshold input. */ thresholdHelper?: string; /** Caption for the threshold input. Defaults to `"Threshold"`. */ thresholdLabel?: string; }; const DEFAULT_LABELS = { disabledFallback: "Auto-reload is unavailable for this account.", heading: "Auto-reload", helper: "Automatically reload credits when balance is low.", reloadHelper: "Amount to add when the threshold is hit.", reloadLabel: "Reload amount", save: "Save settings", saving: "Saving…", thresholdHelper: "Reload when the balance drops below this.", thresholdLabel: "Threshold", } as const satisfies Required<AutoReloadLabels>; /** * Props for {@link AutoReload}. * * @public */ export type AutoReloadProps = { /** Currency code (ISO 4217). Defaults to `"USD"`. */ currency?: string; /** Override the symbol displayed inside the inputs (e.g. `"€"`). */ currencySymbol?: string; /** Initial enabled state when uncontrolled. */ defaultEnabled?: boolean; /** Initial reload-amount value (cents) when uncontrolled. */ defaultReloadAmountCents?: number; /** Initial threshold value (cents) when uncontrolled. */ defaultThresholdCents?: number; /** When true, the consumer cannot interact with the control. */ disabled?: boolean; /** Caption rendered with `disabled`. */ disabledMessage?: ReactNode; /** Controlled enabled state. */ enabled?: boolean; /** When true, the save button renders as loading + disabled. */ isSaving?: boolean; /** Localizable strings. */ labels?: AutoReloadLabels; /** BCP-47 locale tag. Defaults to `"en-US"`. */ locale?: string; /** Highest allowed amount (cents). */ maxAmountCents?: number; /** Lowest allowed amount (cents). Defaults to `100`. */ minAmountCents?: number; /** Fires when the reload-amount input changes (cents). Required to keep `reloadAmountCents` controlled. */ onReloadAmountChange?: (cents: number) => void; /** Fires when the user clicks save. */ onSave?: (payload: AutoReloadSavePayload) => void; /** Fires when the threshold input changes (cents). Required to keep `thresholdCents` controlled. */ onThresholdChange?: (cents: number) => void; /** Fires when the toggle changes. */ onToggle?: (enabled: boolean) => void; /** Controlled reload-amount value (cents). */ reloadAmountCents?: number; /** Step granularity for the inputs (cents). Defaults to `100`. */ stepCents?: number; /** Controlled threshold value (cents). */ thresholdCents?: number; } & ComponentPropsWithoutRef<"div">; function centsToValue(cents: number): string { return (cents / CENTS_PER_UNIT).toFixed(2); } function valueToCents(value: string): number { const parsed = Number.parseFloat(value); if (Number.isNaN(parsed)) return 0; return Math.round(parsed * CENTS_PER_UNIT); } const CURRENCY_FORMATTER_CACHE = new Map<string, Intl.NumberFormat>(); function getCurrencyFormatter( locale: string, currency: string, ): Intl.NumberFormat { const key = `${locale}|${currency}`; let formatter = CURRENCY_FORMATTER_CACHE.get(key); if (!formatter) { formatter = Intl.NumberFormat(locale, { currency, style: "currency", }); CURRENCY_FORMATTER_CACHE.set(key, formatter); } return formatter; } function getCurrencySymbol(locale: string, currency: string): string { const formatted = getCurrencyFormatter(locale, currency).format(0); const symbol = formatted.replaceAll(/[\d\s,.]/g, ""); return symbol.length > 0 ? symbol : currency; } type ReloadFormProps = { currencyDisplay: string; isSaving: boolean; labels: Required<AutoReloadLabels>; maxAmountCents?: number; minAmountCents: number; onSave: () => void; reloadAmount: number; reloadAmountId: string; setReloadAmount: (value: number) => void; setThreshold: (value: number) => void; stepCents: number; threshold: number; thresholdId: string; }; function ReloadFormFields({ currencyDisplay, isSaving, labels, maxAmountCents, minAmountCents, onSave, reloadAmount, reloadAmountId, setReloadAmount, setThreshold, stepCents, threshold, thresholdId, }: ReloadFormProps): ReactNode { const handleThreshold = useCallback( (event: ChangeEvent<HTMLInputElement>) => { setThreshold(valueToCents(event.target.value)); }, [setThreshold], ); const handleReload = useCallback( (event: ChangeEvent<HTMLInputElement>) => { setReloadAmount(valueToCents(event.target.value)); }, [setReloadAmount], ); const step = (stepCents / CENTS_PER_UNIT).toFixed(2); const min = (minAmountCents / CENTS_PER_UNIT).toFixed(2); const max = maxAmountCents === undefined ? undefined : (maxAmountCents / CENTS_PER_UNIT).toFixed(2); return ( <div className="flex flex-col gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <NumericField currencyDisplay={currencyDisplay} helper={labels.thresholdHelper} id={thresholdId} label={labels.thresholdLabel} max={max} min={min} onChange={handleThreshold} step={step} value={centsToValue(threshold)} /> <NumericField currencyDisplay={currencyDisplay} helper={labels.reloadHelper} id={reloadAmountId} label={labels.reloadLabel} max={max} min={min} onChange={handleReload} step={step} value={centsToValue(reloadAmount)} /> </div> <div> <Button disabled={isSaving} onClick={onSave} type="button"> {isSaving ? labels.saving : labels.save} </Button> </div> </div> ); } type NumericFieldProps = { currencyDisplay: string; helper?: string; id: string; label: string; max?: string; min?: string; onChange: (event: ChangeEvent<HTMLInputElement>) => void; step: string; value: string; }; function NumericField({ currencyDisplay, helper, id, label, max, min, onChange, step, value, }: NumericFieldProps): ReactNode { return ( <div className="flex flex-col gap-1"> <label className="text-sm font-medium text-foreground" htmlFor={id}> {label} ({currencyDisplay}) </label> <Input id={id} inputMode="decimal" max={max} min={min} onChange={onChange} step={step} type="number" value={value} /> {helper ? ( <p className="text-xs text-muted-foreground">{helper}</p> ) : null} </div> ); } type ToggleHeaderProps = { enabled: boolean; heading: string; helper: string; id: string; onCheckedChange: (next: boolean) => void; }; function ToggleHeader({ enabled, heading, helper, id, onCheckedChange, }: ToggleHeaderProps): ReactNode { const helperId = `${id}-helper`; return ( <header className="flex items-start justify-between gap-3"> <div className="flex flex-col gap-1"> <span className="text-sm font-semibold text-foreground" id={id}> {heading} </span> <span className="text-xs text-muted-foreground" id={helperId}> {helper} </span> </div> <Switch aria-describedby={helperId} aria-labelledby={id} checked={enabled} onCheckedChange={onCheckedChange} /> </header> ); } type ControllerOptions = { defaultEnabled: boolean; defaultReloadAmountCents: number; defaultThresholdCents: number; enabled?: boolean; onReloadAmountChange?: (cents: number) => void; onThresholdChange?: (cents: number) => void; onToggle?: (enabled: boolean) => void; reloadAmountCents?: number; thresholdCents?: number; }; type ControllerState = { enabled: boolean; handleToggle: (next: boolean) => void; reloadAmount: number; setReloadAmount: (value: number) => void; setThreshold: (value: number) => void; threshold: number; }; function useAutoReloadController(options: ControllerOptions): ControllerState { const { defaultEnabled, defaultReloadAmountCents, defaultThresholdCents, enabled: controlledEnabled, onReloadAmountChange, onThresholdChange, onToggle, reloadAmountCents: controlledReload, thresholdCents: controlledThreshold, } = options; const [uncontrolledEnabled, setUncontrolledEnabled] = useState(defaultEnabled); const [uncontrolledThreshold, setUncontrolledThreshold] = useState( defaultThresholdCents, ); const [uncontrolledReloadAmount, setUncontrolledReloadAmount] = useState( defaultReloadAmountCents, ); const enabled = controlledEnabled ?? uncontrolledEnabled; const handleToggle = useCallback( (next: boolean) => { if (controlledEnabled === undefined) setUncontrolledEnabled(next); onToggle?.(next); }, [controlledEnabled, onToggle], ); const setReloadAmount = useCallback( (next: number) => { if (controlledReload === undefined) setUncontrolledReloadAmount(next); onReloadAmountChange?.(next); }, [controlledReload, onReloadAmountChange], ); const setThreshold = useCallback( (next: number) => { if (controlledThreshold === undefined) setUncontrolledThreshold(next); onThresholdChange?.(next); }, [controlledThreshold, onThresholdChange], ); return { enabled, handleToggle, reloadAmount: controlledReload ?? uncontrolledReloadAmount, setReloadAmount, setThreshold, threshold: controlledThreshold ?? uncontrolledThreshold, }; } /** * Toggle + collapsible configuration form for automatic credit reloading. * Composes {@link Switch}, {@link Input}, and {@link Button}. Renders a * disabled banner when the consumer passes `disabled` so the control can * advertise itself before the user has access (e.g. before subscribing). * * Values flow through the component in **minor units** (cents). Inputs * display the corresponding currency unit; consumers receive the cents * value from `onSave`. * * When you pass the controlled `reloadAmountCents` / `thresholdCents` props * you must also pass `onReloadAmountChange` / `onThresholdChange` and feed the * value back, otherwise the component ignores user edits. Omit the controlled * props to run uncontrolled with `defaultReloadAmountCents` / * `defaultThresholdCents`. * * @example * ```tsx * <AutoReload * enabled={settings.autoReloadEnabled} * thresholdCents={settings.thresholdCents} * reloadAmountCents={settings.reloadAmountCents} * currency="EUR" * onToggle={handleToggle} * onThresholdChange={setThresholdCents} * onReloadAmountChange={setReloadAmountCents} * onSave={handleSave} * isSaving={isSaving} * /> * ``` * * @public */ type DisabledBannerProps = { className?: string; message: ReactNode; } & ComponentPropsWithoutRef<"div">; const DisabledBanner = ({ className, message, ref, ...rest }: DisabledBannerProps & { ref?: React.Ref<HTMLDivElement> }) => ( <div aria-disabled="true" className={cn( "flex items-start gap-3 rounded-2xl border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground", className, )} ref={ref} {...rest} > {message} </div> ); DisabledBanner.displayName = "AutoReload.DisabledBanner"; type ActivePanelProps = { className?: string; controller: ControllerState; currencyDisplay: string; isSaving: boolean; labels: Required<AutoReloadLabels>; maxAmountCents?: number; minAmountCents: number; onSave: () => void; reloadAmountId: string; stepCents: number; thresholdId: string; toggleId: string; } & ComponentPropsWithoutRef<"div">; const ActivePanel = ({ ref, ...props }: ActivePanelProps & { ref?: React.Ref<HTMLDivElement> }) => { const { className, controller, currencyDisplay, isSaving, labels, maxAmountCents, minAmountCents, onSave, reloadAmountId, stepCents, thresholdId, toggleId, ...rest } = props; return ( <div className={cn( "flex flex-col gap-4 rounded-2xl border bg-background p-4", className, )} ref={ref} {...rest} > <ToggleHeader enabled={controller.enabled} heading={labels.heading} helper={labels.helper} id={toggleId} onCheckedChange={controller.handleToggle} /> {controller.enabled ? ( <ReloadFormFields currencyDisplay={currencyDisplay} isSaving={isSaving} labels={labels} maxAmountCents={maxAmountCents} minAmountCents={minAmountCents} onSave={onSave} reloadAmount={controller.reloadAmount} reloadAmountId={reloadAmountId} setReloadAmount={controller.setReloadAmount} setThreshold={controller.setThreshold} stepCents={stepCents} threshold={controller.threshold} thresholdId={thresholdId} /> ) : null} </div> ); }; ActivePanel.displayName = "AutoReload.ActivePanel"; type AutoReloadInternalState = { controller: ControllerState; currencyDisplay: string; handleSave: () => void; reloadAmountId: string; resolvedLabels: Required<AutoReloadLabels>; thresholdId: string; toggleId: string; }; function useAutoReloadInternals( props: AutoReloadProps, ): AutoReloadInternalState { const { currency = DEFAULT_CURRENCY, currencySymbol, defaultEnabled = false, defaultReloadAmountCents = 2000, defaultThresholdCents = 1000, enabled: controlledEnabled, labels, locale = DEFAULT_LOCALE, onReloadAmountChange, onSave, onThresholdChange, onToggle, reloadAmountCents: controlledReload, thresholdCents: controlledThreshold, } = props; const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labels }), [labels], ); const toggleId = useId(); const thresholdId = useId(); const reloadAmountId = useId(); const controller = useAutoReloadController({ defaultEnabled, defaultReloadAmountCents, defaultThresholdCents, enabled: controlledEnabled, onReloadAmountChange, onThresholdChange, onToggle, reloadAmountCents: controlledReload, thresholdCents: controlledThreshold, }); const currencyDisplay = currencySymbol ?? getCurrencySymbol(locale, currency); const handleSave = useCallback(() => { onSave?.({ reloadAmountCents: controller.reloadAmount, thresholdCents: controller.threshold, }); }, [controller.reloadAmount, controller.threshold, onSave]); return { controller, currencyDisplay, handleSave, reloadAmountId, resolvedLabels, thresholdId, toggleId, }; } export const AutoReload = ({ ref, ...props }: AutoReloadProps & { ref?: React.Ref<HTMLDivElement> }) => { const { className, disabled = false, disabledMessage, isSaving = false, maxAmountCents, minAmountCents = 100, stepCents = DEFAULT_STEP_CENTS, } = props; const internals = useAutoReloadInternals(props); if (disabled) { return ( <DisabledBanner className={className} message={disabledMessage ?? internals.resolvedLabels.disabledFallback} ref={ref} /> ); } return ( <ActivePanel className={className} controller={internals.controller} currencyDisplay={internals.currencyDisplay} isSaving={isSaving} labels={internals.resolvedLabels} maxAmountCents={maxAmountCents} minAmountCents={minAmountCents} onSave={internals.handleSave} ref={ref} reloadAmountId={internals.reloadAmountId} stepCents={stepCents} thresholdId={internals.thresholdId} toggleId={internals.toggleId} /> ); }; AutoReload.displayName = "AutoReload";

Dependencies

  • @vllnt/ui@^0.3.0