| 1 | 'use client' |
| 2 | |
| 3 | import * as React from 'react' |
| 4 | import { CurrencySelect } |
| 5 | import { Input } |
| 6 | import { |
| 7 | Select, |
| 8 | SelectContent, |
| 9 | SelectItem, |
| 10 | SelectTrigger, |
| 11 | SelectValue, |
| 12 | } from '@/components/ui/select' |
| 13 | import { cn } |
| 14 | |
| 15 | const DEFAULT_FREQUENCIES = [ |
| 16 | 'hourly', |
| 17 | 'weekly', |
| 18 | 'biweekly', |
| 19 | 'monthly', |
| 20 | ] as const |
| 21 | |
| 22 | export const FREQUENCY_LABELS: |
| 23 | hourly: 'Hourly', |
| 24 | weekly: 'Weekly', |
| 25 | biweekly: 'Bi-Weekly', |
| 26 | monthly: 'Monthly', |
| 27 | } |
| 28 | |
| 29 | export const FREQUENCY_SHORTHAND: |
| 30 | hourly: '/h', |
| 31 | weekly: '/w', |
| 32 | biweekly: '/bw', |
| 33 | monthly: '/m', |
| 34 | } |
| 35 | |
| 36 | // Only allow numbers with up to two decimal places (or an empty string). |
| 37 | const AMOUNT_PATTERN = / |
| 38 | |
| 39 | /** Format an amount in minor units (cents) into a display string. */ |
| 40 | function centsToText(value: |
| 41 | if (!value) |
| 42 | return String(value |
| 43 | } |
| 44 | |
| 45 | export interface RateInputProps |
| 46 | /** Amount in minor units (cents). */ |
| 47 | value: number |
| 48 | /** |
| 49 | * Called with the amount in cents. Emits `undefined` when the field is |
| 50 | * cleared and `allowEmpty` is set, otherwise `0`. |
| 51 | */ |
| 52 | onValueChange: (value |
| 53 | currency: string |
| 54 | onCurrencyChange: (currency |
| 55 | frequency: string |
| 56 | onFrequencyChange: (frequency |
| 57 | /** Frequency options to offer. Defaults to all billing frequencies. */ |
| 58 | frequencies?: readonly |
| 59 | /** When true, clearing the input emits `undefined` instead of `0`. */ |
| 60 | allowEmpty?: boolean |
| 61 | /** Render frequencies as shorthand (`/h`, `/m`) instead of full labels. */ |
| 62 | useFrequencyShorthand?: boolean |
| 63 | placeholder?: string |
| 64 | /** Name applied to the currency selector (for forms/labels). */ |
| 65 | name?: string |
| 66 | disabled?: boolean |
| 67 | invalid?: boolean |
| 68 | className?: string |
| 69 | } |
| 70 | |
| 71 | export function RateInput({ |
| 72 | value, |
| 73 | onValueChange, |
| 74 | currency, |
| 75 | onCurrencyChange, |
| 76 | frequency, |
| 77 | onFrequencyChange, |
| 78 | frequencies = DEFAULT_FREQUENCIES, |
| 79 | allowEmpty = false, |
| 80 | useFrequencyShorthand = false, |
| 81 | placeholder = '0.00' |
| 82 | name = 'currency' |
| 83 | disabled, |
| 84 | invalid, |
| 85 | className, |
| 86 | }: RateInputProps) |
| 87 | // Keep a local string so partial input ("1.", "0.0") and clearing survive |
| 88 | // the round-trip through cents. We only resync from `value` while the field |
| 89 | // is not focused (e.g. an external reset), so typing is never interrupted. |
| 90 | const [text, |
| 91 | const [focused, |
| 92 | |
| 93 | React.useEffect(() |
| 94 | if (!focused) |
| 95 | }, [value, |
| 96 | |
| 97 | function handleChange(e |
| 98 | const raw = |
| 99 | if (raw !== |
| 100 | |
| 101 | setText(raw) |
| 102 | |
| 103 | if (raw === |
| 104 | onValueChange(allowEmpty ? |
| 105 | return |
| 106 | } |
| 107 | |
| 108 | const parsed = |
| 109 | if (Number.isNaN |
| 110 | onValueChange(Math.round |
| 111 | } |
| 112 | |
| 113 | return ( |
| 114 | <div |
| 115 | aria-invalid={invalid} |
| 116 | className={cn( |
| 117 | 'flex h-9 items-stretch overflow-hidden rounded-md border border-input shadow-xs transition-[color,box-shadow]', |
| 118 | 'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50', |
| 119 | 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', |
| 120 | disabled && 'pointer-events-none opacity-50' |
| 121 | className |
| 122 | )} |
| 123 | > |
| 124 | <CurrencySelect |
| 125 | className='h-full w-auto gap-1 rounded-none border-0 px-3 shadow-none focus-visible:ring-0' |
| 126 | disabled={disabled} |
| 127 | name={name} |
| 128 | onValueChange={onCurrencyChange} |
| 129 | value={currency} |
| 130 | variant='small' |
| 131 | /> |
| 132 | <Input |
| 133 | className='h-full flex-1 rounded-none border-0 border-input border-x text-right shadow-none focus-visible:ring-0' |
| 134 | disabled={disabled} |
| 135 | inputMode='decimal' |
| 136 | onBlur={() => |
| 137 | onChange={handleChange} |
| 138 | onFocus={() => |
| 139 | placeholder={placeholder} |
| 140 | value={text} |
| 141 | /> |
| 142 | <Select |
| 143 | disabled={disabled} |
| 144 | onValueChange={onFrequencyChange} |
| 145 | value={frequency} |
| 146 | > |
| 147 | <SelectTrigger className=' |
| 148 | <SelectValue /> |
| 149 | </SelectTrigger> |
| 150 | <SelectContent align=' |
| 151 | {frequencies.map(( |
| 152 | <SelectItem key={ |
| 153 | {(useFrequencyShorthand ? FREQUENCY_SHORTHAND |
| 154 | f |
| 155 | ] ?? f} |
| 156 | </SelectItem> |
| 157 | ))} |
| 158 | </SelectContent> |
| 159 | </Select> |
| 160 | </div> |
| 161 | ) |
| 162 | } |