@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectItemLeading,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
} from './select'
|
|
15
|
+
|
|
16
|
+
export type AmountCurrency = {
|
|
17
|
+
/** ISO 4217 code (e.g. `'EUR'`, `'USD'`, `'PLN'`). */
|
|
18
|
+
code: string
|
|
19
|
+
/** Symbol shown as the leading prefix inside the amount input (e.g. `'€'`). */
|
|
20
|
+
symbol: string
|
|
21
|
+
/** Human-readable label for the picker dropdown. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Optional emoji flag (or any leading visual) for the picker row. */
|
|
24
|
+
flag?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Static currency list — extend as needed. Order is alphabetical by code so the picker reads
|
|
29
|
+
* predictably; the first entry is the default fallback when `value.currency` is empty or
|
|
30
|
+
* unrecognised.
|
|
31
|
+
*/
|
|
32
|
+
export const AMOUNT_CURRENCIES: AmountCurrency[] = [
|
|
33
|
+
{ code: 'EUR', symbol: '€', label: 'Euro', flag: '🇪🇺' },
|
|
34
|
+
{ code: 'USD', symbol: '$', label: 'US Dollar', flag: '🇺🇸' },
|
|
35
|
+
{ code: 'GBP', symbol: '£', label: 'Pound Sterling', flag: '🇬🇧' },
|
|
36
|
+
{ code: 'PLN', symbol: 'zł', label: 'Polish Złoty', flag: '🇵🇱' },
|
|
37
|
+
{ code: 'CHF', symbol: 'CHF', label: 'Swiss Franc', flag: '🇨🇭' },
|
|
38
|
+
{ code: 'SEK', symbol: 'kr', label: 'Swedish Krona', flag: '🇸🇪' },
|
|
39
|
+
{ code: 'CZK', symbol: 'Kč', label: 'Czech Koruna', flag: '🇨🇿' },
|
|
40
|
+
{ code: 'JPY', symbol: '¥', label: 'Japanese Yen', flag: '🇯🇵' },
|
|
41
|
+
{ code: 'AUD', symbol: 'A$', label: 'Australian Dollar', flag: '🇦🇺' },
|
|
42
|
+
{ code: 'CAD', symbol: 'C$', label: 'Canadian Dollar', flag: '🇨🇦' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CURRENCY = AMOUNT_CURRENCIES[0]
|
|
46
|
+
|
|
47
|
+
export type AmountValue = {
|
|
48
|
+
/** Raw numeric amount as a string (preserves leading zeros, partial decimals like `'12.'`). */
|
|
49
|
+
amount: string
|
|
50
|
+
/** ISO 4217 currency code from the picker. */
|
|
51
|
+
currency: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AmountInputProps = Omit<
|
|
55
|
+
React.ComponentPropsWithoutRef<'input'>,
|
|
56
|
+
'size' | 'type' | 'value' | 'onChange'
|
|
57
|
+
> &
|
|
58
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
59
|
+
value: AmountValue
|
|
60
|
+
onChange: (next: AmountValue) => void
|
|
61
|
+
/** Override the static currency list (e.g. limit to specific markets). */
|
|
62
|
+
currencies?: AmountCurrency[]
|
|
63
|
+
/** Hide the currency picker entirely — single-currency surfaces (e.g. settings page). */
|
|
64
|
+
showCurrency?: boolean
|
|
65
|
+
/** Optional className on the wrapper. */
|
|
66
|
+
className?: string
|
|
67
|
+
/** Optional className on the inner `<input>`. */
|
|
68
|
+
inputClassName?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findCurrency(code: string, list: AmountCurrency[]): AmountCurrency {
|
|
72
|
+
return list.find((c) => c.code === code) ?? list[0] ?? DEFAULT_CURRENCY
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Amount input matching Figma `Text Input [1.1]` (node `266:5251`) **Amount** variant — leading
|
|
77
|
+
* currency symbol inside the input, then a vertical divider, then a `Select`-driven currency
|
|
78
|
+
* picker (flag + code + chevron). Numeric `inputMode="decimal"`.
|
|
79
|
+
*
|
|
80
|
+
* Value shape is `{ amount: string, currency: string }` — the amount is stored as a string to
|
|
81
|
+
* preserve raw user input (leading zeros, in-progress decimals). Parse to `Number` at the
|
|
82
|
+
* API/persistence boundary.
|
|
83
|
+
*/
|
|
84
|
+
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
|
85
|
+
(
|
|
86
|
+
{
|
|
87
|
+
className,
|
|
88
|
+
inputClassName,
|
|
89
|
+
size,
|
|
90
|
+
value,
|
|
91
|
+
onChange,
|
|
92
|
+
currencies: currenciesProp,
|
|
93
|
+
showCurrency = true,
|
|
94
|
+
placeholder,
|
|
95
|
+
disabled,
|
|
96
|
+
...props
|
|
97
|
+
},
|
|
98
|
+
ref,
|
|
99
|
+
) => {
|
|
100
|
+
const t = useT()
|
|
101
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.amountInput.placeholder', '0.00')
|
|
102
|
+
const currencies = currenciesProp ?? AMOUNT_CURRENCIES
|
|
103
|
+
const current = findCurrency(value.currency, currencies)
|
|
104
|
+
|
|
105
|
+
const handleAmountChange = React.useCallback(
|
|
106
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
107
|
+
onChange({ amount: event.target.value, currency: current.code })
|
|
108
|
+
},
|
|
109
|
+
[current.code, onChange],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const handleCurrencyChange = React.useCallback(
|
|
113
|
+
(nextCode: string) => {
|
|
114
|
+
onChange({ amount: value.amount, currency: nextCode })
|
|
115
|
+
},
|
|
116
|
+
[onChange, value.amount],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={cn(inputWrapperVariants({ size }), 'px-0 overflow-hidden', className)}
|
|
122
|
+
data-slot="amount-input-wrapper"
|
|
123
|
+
>
|
|
124
|
+
<div className="flex flex-1 min-w-0 items-center gap-2 pl-3">
|
|
125
|
+
<span
|
|
126
|
+
className="text-sm text-muted-foreground select-none shrink-0 tabular-nums"
|
|
127
|
+
aria-hidden="true"
|
|
128
|
+
>
|
|
129
|
+
{current.symbol}
|
|
130
|
+
</span>
|
|
131
|
+
<input
|
|
132
|
+
ref={ref}
|
|
133
|
+
type="text"
|
|
134
|
+
inputMode="decimal"
|
|
135
|
+
value={value.amount}
|
|
136
|
+
onChange={handleAmountChange}
|
|
137
|
+
placeholder={resolvedPlaceholder}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
className={cn(
|
|
140
|
+
inputElementVariants({ size }),
|
|
141
|
+
'pr-3 tabular-nums',
|
|
142
|
+
inputClassName,
|
|
143
|
+
)}
|
|
144
|
+
{...props}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
{showCurrency ? (
|
|
148
|
+
<>
|
|
149
|
+
<div aria-hidden="true" className="w-px self-stretch bg-input" />
|
|
150
|
+
<Select value={current.code} onValueChange={handleCurrencyChange} disabled={disabled}>
|
|
151
|
+
<SelectTrigger
|
|
152
|
+
aria-label={t('ui.inputs.amountInput.currencyLabel', 'Currency')}
|
|
153
|
+
className={cn(
|
|
154
|
+
'h-auto w-auto shrink-0 gap-1.5 rounded-none border-0 bg-transparent px-2.5 py-2 shadow-none',
|
|
155
|
+
'hover:bg-muted/40 focus:bg-muted/40 focus-visible:shadow-none focus-visible:border-0',
|
|
156
|
+
'disabled:bg-transparent disabled:hover:bg-transparent',
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{current.flag ? (
|
|
160
|
+
<span className="text-base leading-none" aria-hidden="true">{current.flag}</span>
|
|
161
|
+
) : null}
|
|
162
|
+
<span className="text-sm text-foreground tabular-nums">{current.code}</span>
|
|
163
|
+
</SelectTrigger>
|
|
164
|
+
<SelectContent align="end">
|
|
165
|
+
{currencies.map((c) => (
|
|
166
|
+
<SelectItem key={c.code} value={c.code}>
|
|
167
|
+
{c.flag ? (
|
|
168
|
+
<SelectItemLeading>
|
|
169
|
+
<span className="text-base leading-none">{c.flag}</span>
|
|
170
|
+
</SelectItemLeading>
|
|
171
|
+
) : null}
|
|
172
|
+
<span className="flex-1 truncate">{c.label}</span>
|
|
173
|
+
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{c.code}</span>
|
|
174
|
+
</SelectItem>
|
|
175
|
+
))}
|
|
176
|
+
</SelectContent>
|
|
177
|
+
</Select>
|
|
178
|
+
</>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
AmountInput.displayName = 'AmountInput'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
5
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
7
|
+
|
|
8
|
+
export type ButtonInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> &
|
|
9
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
10
|
+
/** Optional leading icon (decorative `aria-hidden` wrapper). */
|
|
11
|
+
leftIcon?: React.ReactNode
|
|
12
|
+
/**
|
|
13
|
+
* Required interactive trailing element rendered inside the wrapper, after the vertical
|
|
14
|
+
* divider. Typically an `<IconButton>` (copy URL, send, refresh). The slot stays focusable
|
|
15
|
+
* and screen-reader accessible — do NOT pass a decorative icon here.
|
|
16
|
+
*/
|
|
17
|
+
trailingAction: React.ReactNode
|
|
18
|
+
/** Optional className on the wrapper. */
|
|
19
|
+
className?: string
|
|
20
|
+
/** Optional className on the inner `<input>`. */
|
|
21
|
+
inputClassName?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Button input matching Figma `Text Input [1.1]` (node `266:5251`) **Button** variant — a text
|
|
26
|
+
* input with an interactive trailing button slot, separated by a vertical divider. Built on the
|
|
27
|
+
* shared `inputWrapperVariants` / `inputElementVariants` CVA so the visual contract matches the
|
|
28
|
+
* foundation `Input` primitive.
|
|
29
|
+
*
|
|
30
|
+
* Common pairings: share-link + copy button, subscribe-email + send button, API key + regenerate
|
|
31
|
+
* button. The trailing slot is rendered AS-IS (no auto-wrapping), so consumers control button
|
|
32
|
+
* type, size, variant, and `aria-label` directly via the passed element.
|
|
33
|
+
*/
|
|
34
|
+
export const ButtonInput = React.forwardRef<HTMLInputElement, ButtonInputProps>(
|
|
35
|
+
({ className, inputClassName, size, leftIcon, trailingAction, disabled, ...props }, ref) => {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(inputWrapperVariants({ size }), 'px-0 overflow-hidden', className)}
|
|
39
|
+
data-slot="button-input-wrapper"
|
|
40
|
+
>
|
|
41
|
+
<div className="flex flex-1 min-w-0 items-center gap-2 pl-3 pr-2">
|
|
42
|
+
{leftIcon ? (
|
|
43
|
+
<span
|
|
44
|
+
className="flex shrink-0 items-center text-muted-foreground [&_svg]:size-4"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
>
|
|
47
|
+
{leftIcon}
|
|
48
|
+
</span>
|
|
49
|
+
) : null}
|
|
50
|
+
<input
|
|
51
|
+
ref={ref}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
className={cn(
|
|
54
|
+
inputElementVariants({ size }),
|
|
55
|
+
inputClassName,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div aria-hidden="true" className="w-px self-stretch bg-input" />
|
|
61
|
+
<div className="flex shrink-0 items-stretch">
|
|
62
|
+
{trailingAction}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
ButtonInput.displayName = 'ButtonInput'
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { CreditCard } from 'lucide-react'
|
|
5
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
9
|
+
|
|
10
|
+
export type CardBrand = {
|
|
11
|
+
/** Stable identifier (e.g. `'visa'`, `'mastercard'`). Used as the `data-card-brand` attribute. */
|
|
12
|
+
id: string
|
|
13
|
+
/** Short label rendered on the trailing badge (e.g. `'VISA'`, `'MC'`). */
|
|
14
|
+
label: string
|
|
15
|
+
/** Regex pattern matched against the digits-only card number. */
|
|
16
|
+
regex: RegExp
|
|
17
|
+
/** Grouping for display (e.g. `[4, 4, 4, 4]` for Visa, `[4, 6, 5]` for Amex). */
|
|
18
|
+
format: number[]
|
|
19
|
+
/** Maximum number of digits allowed (Visa/MC/Disc 16, Amex 15, Diners 14). */
|
|
20
|
+
maxLength: number
|
|
21
|
+
/** Background color for the brand badge. */
|
|
22
|
+
bg: string
|
|
23
|
+
/** Foreground (text) color for the brand badge label. Defaults to white. */
|
|
24
|
+
fg?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default card brand list — covers the most common networks. Order matters: the first matching
|
|
29
|
+
* regex wins, so brands with broader prefixes (e.g. UnionPay `^62`) MUST come after narrower ones
|
|
30
|
+
* (e.g. Discover `^622`). Override with the `brands` prop for region-specific surfaces.
|
|
31
|
+
*/
|
|
32
|
+
export const CARD_BRANDS: CardBrand[] = [
|
|
33
|
+
{ id: 'amex', label: 'AMEX', regex: /^3[47]/, format: [4, 6, 5], maxLength: 15, bg: '#006FCF' },
|
|
34
|
+
{ id: 'visa', label: 'VISA', regex: /^4/, format: [4, 4, 4, 4], maxLength: 16, bg: '#1A1F71' },
|
|
35
|
+
{ id: 'mastercard', label: 'MC', regex: /^(5[1-5]|2[2-7])/, format: [4, 4, 4, 4], maxLength: 16, bg: '#EB001B' },
|
|
36
|
+
{ id: 'discover', label: 'DISC', regex: /^(6011|64[4-9]|65|622)/, format: [4, 4, 4, 4], maxLength: 16, bg: '#FF6000' },
|
|
37
|
+
{ id: 'diners', label: 'DC', regex: /^(36|30[0-5]|309)/, format: [4, 6, 4], maxLength: 14, bg: '#0079BE' },
|
|
38
|
+
{ id: 'jcb', label: 'JCB', regex: /^35(2[89]|[3-8])/, format: [4, 4, 4, 4], maxLength: 16, bg: '#0E4C96' },
|
|
39
|
+
{ id: 'unionpay', label: 'UP', regex: /^62/, format: [4, 4, 4, 4], maxLength: 16, bg: '#E21836' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const DEFAULT_FORMAT: number[] = [4, 4, 4, 4]
|
|
43
|
+
const DEFAULT_MAX_LENGTH = 16
|
|
44
|
+
|
|
45
|
+
function detectBrand(digits: string, brands: CardBrand[]): CardBrand | null {
|
|
46
|
+
if (!digits) return null
|
|
47
|
+
for (const brand of brands) {
|
|
48
|
+
if (brand.regex.test(digits)) return brand
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatDigits(digits: string, format: number[]): string {
|
|
54
|
+
if (!digits) return ''
|
|
55
|
+
const groups: string[] = []
|
|
56
|
+
let cursor = 0
|
|
57
|
+
for (const groupSize of format) {
|
|
58
|
+
if (cursor >= digits.length) break
|
|
59
|
+
groups.push(digits.slice(cursor, cursor + groupSize))
|
|
60
|
+
cursor += groupSize
|
|
61
|
+
}
|
|
62
|
+
if (cursor < digits.length) groups.push(digits.slice(cursor))
|
|
63
|
+
return groups.join(' ')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitize(input: string, maxLength: number): string {
|
|
67
|
+
return input.replace(/\D+/g, '').slice(0, maxLength)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CardInputProps = Omit<
|
|
71
|
+
React.ComponentPropsWithoutRef<'input'>,
|
|
72
|
+
'size' | 'type' | 'value' | 'onChange'
|
|
73
|
+
> &
|
|
74
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
75
|
+
/** Controlled value — digits-only (no spaces). Pass formatted strings if you must; non-digits are stripped. */
|
|
76
|
+
value: string
|
|
77
|
+
/** Called with the new digits-only string. The component handles formatting on display. */
|
|
78
|
+
onChange: (digits: string) => void
|
|
79
|
+
/** Called when the detected brand changes. `null` when nothing matches yet. */
|
|
80
|
+
onBrandChange?: (brand: CardBrand | null) => void
|
|
81
|
+
/** Override the card brand list (e.g. region-specific). */
|
|
82
|
+
brands?: CardBrand[]
|
|
83
|
+
/** Render the leading bank-card icon. Defaults to `true`. */
|
|
84
|
+
showLeadingIcon?: boolean
|
|
85
|
+
/** Render the trailing brand badge. Defaults to `true`. */
|
|
86
|
+
showBrandBadge?: boolean
|
|
87
|
+
/** Optional className on the wrapper. */
|
|
88
|
+
className?: string
|
|
89
|
+
/** Optional className on the inner `<input>`. */
|
|
90
|
+
inputClassName?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Card-number input matching Figma `Text Input [1.1]` (node `266:5251`) **Card** variant — leading
|
|
95
|
+
* `CreditCard` icon, the formatted card-number input, and a trailing brand badge that auto-detects
|
|
96
|
+
* the issuer (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay) from the typed digits.
|
|
97
|
+
*
|
|
98
|
+
* Built on the shared `inputWrapperVariants` / `inputElementVariants` CVA. Brand detection is
|
|
99
|
+
* regex-based — no external library dependency. The `value` prop is digits-only; the component
|
|
100
|
+
* formats per brand on display (`[4,4,4,4]` default, `[4,6,5]` Amex, `[4,6,4]` Diners) and emits
|
|
101
|
+
* digits-only via `onChange`.
|
|
102
|
+
*/
|
|
103
|
+
export const CardInput = React.forwardRef<HTMLInputElement, CardInputProps>(
|
|
104
|
+
(
|
|
105
|
+
{
|
|
106
|
+
className,
|
|
107
|
+
inputClassName,
|
|
108
|
+
size,
|
|
109
|
+
value,
|
|
110
|
+
onChange,
|
|
111
|
+
onBrandChange,
|
|
112
|
+
brands: brandsProp,
|
|
113
|
+
showLeadingIcon = true,
|
|
114
|
+
showBrandBadge = true,
|
|
115
|
+
placeholder,
|
|
116
|
+
disabled,
|
|
117
|
+
...props
|
|
118
|
+
},
|
|
119
|
+
ref,
|
|
120
|
+
) => {
|
|
121
|
+
const t = useT()
|
|
122
|
+
const brands = brandsProp ?? CARD_BRANDS
|
|
123
|
+
const sanitizedValue = sanitize(value ?? '', DEFAULT_MAX_LENGTH)
|
|
124
|
+
const detected = detectBrand(sanitizedValue, brands)
|
|
125
|
+
const format = detected?.format ?? DEFAULT_FORMAT
|
|
126
|
+
const maxLength = detected?.maxLength ?? DEFAULT_MAX_LENGTH
|
|
127
|
+
const truncated = sanitizedValue.slice(0, maxLength)
|
|
128
|
+
const displayed = formatDigits(truncated, format)
|
|
129
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.cardInput.placeholder', '0000 0000 0000 0000')
|
|
130
|
+
|
|
131
|
+
const lastBrandRef = React.useRef<string | null>(null)
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
const next = detected?.id ?? null
|
|
134
|
+
if (next !== lastBrandRef.current) {
|
|
135
|
+
lastBrandRef.current = next
|
|
136
|
+
onBrandChange?.(detected)
|
|
137
|
+
}
|
|
138
|
+
}, [detected, onBrandChange])
|
|
139
|
+
|
|
140
|
+
const handleChange = React.useCallback(
|
|
141
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
142
|
+
const nextBrand = detectBrand(sanitize(event.target.value, DEFAULT_MAX_LENGTH), brands)
|
|
143
|
+
const nextMax = nextBrand?.maxLength ?? DEFAULT_MAX_LENGTH
|
|
144
|
+
const digits = sanitize(event.target.value, nextMax)
|
|
145
|
+
onChange(digits)
|
|
146
|
+
},
|
|
147
|
+
[brands, onChange],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className={cn(inputWrapperVariants({ size }), className)}
|
|
153
|
+
data-slot="card-input-wrapper"
|
|
154
|
+
data-card-brand={detected?.id ?? 'unknown'}
|
|
155
|
+
>
|
|
156
|
+
{showLeadingIcon ? (
|
|
157
|
+
<CreditCard className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
158
|
+
) : null}
|
|
159
|
+
<input
|
|
160
|
+
ref={ref}
|
|
161
|
+
type="text"
|
|
162
|
+
inputMode="numeric"
|
|
163
|
+
autoComplete="cc-number"
|
|
164
|
+
value={displayed}
|
|
165
|
+
onChange={handleChange}
|
|
166
|
+
placeholder={resolvedPlaceholder}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
className={cn(
|
|
169
|
+
inputElementVariants({ size }),
|
|
170
|
+
'tracking-[0.02em] tabular-nums',
|
|
171
|
+
inputClassName,
|
|
172
|
+
)}
|
|
173
|
+
{...props}
|
|
174
|
+
/>
|
|
175
|
+
{showBrandBadge && detected ? (
|
|
176
|
+
<span
|
|
177
|
+
className="flex shrink-0 items-center justify-center rounded-sm px-1.5 text-overline font-semibold uppercase tracking-wider tabular-nums leading-none select-none"
|
|
178
|
+
style={{
|
|
179
|
+
backgroundColor: detected.bg,
|
|
180
|
+
color: detected.fg ?? '#ffffff',
|
|
181
|
+
height: '24px',
|
|
182
|
+
minWidth: '32px',
|
|
183
|
+
}}
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
>
|
|
186
|
+
{detected.label}
|
|
187
|
+
</span>
|
|
188
|
+
) : null}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
CardInput.displayName = 'CardInput'
|
|
@@ -16,12 +16,17 @@ const DialogClose = DialogPrimitive.Close
|
|
|
16
16
|
|
|
17
17
|
const DialogOverlay = React.forwardRef<
|
|
18
18
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
19
|
-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
20
|
-
|
|
19
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
|
20
|
+
/** Render above popovers (z-modal-elevated, 55) instead of the default z-modal (40).
|
|
21
|
+
* Use when this dialog is opened from inside a popover so it isn't occluded. */
|
|
22
|
+
elevated?: boolean
|
|
23
|
+
}
|
|
24
|
+
>(({ className, elevated, ...props }, ref) => (
|
|
21
25
|
<DialogPrimitive.Overlay
|
|
22
26
|
ref={ref}
|
|
23
27
|
className={cn(
|
|
24
|
-
'fixed inset-0
|
|
28
|
+
'fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
29
|
+
elevated ? 'z-modal-elevated' : 'z-modal',
|
|
25
30
|
className
|
|
26
31
|
)}
|
|
27
32
|
{...props}
|
|
@@ -31,8 +36,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
|
31
36
|
|
|
32
37
|
const DialogContent = React.forwardRef<
|
|
33
38
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
34
|
-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
35
|
-
|
|
39
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|
40
|
+
/** Render above popovers (z-modal-elevated, 55) instead of the default z-modal (40).
|
|
41
|
+
* Set on dialogs that open from inside another popover (e.g. the SaveFilterDialog
|
|
42
|
+
* inside the AdvancedFilterPanel popover) so they aren't hidden behind the popover. */
|
|
43
|
+
elevated?: boolean
|
|
44
|
+
}
|
|
45
|
+
>(({ className, children, elevated, ...props }, ref) => {
|
|
36
46
|
const t = useT()
|
|
37
47
|
|
|
38
48
|
React.useEffect(() => {
|
|
@@ -48,12 +58,13 @@ const DialogContent = React.forwardRef<
|
|
|
48
58
|
|
|
49
59
|
return (
|
|
50
60
|
<DialogPortal>
|
|
51
|
-
<DialogOverlay />
|
|
61
|
+
<DialogOverlay elevated={elevated} />
|
|
52
62
|
<DialogPrimitive.Content
|
|
53
63
|
ref={ref}
|
|
54
64
|
data-dialog-content=""
|
|
55
65
|
className={cn(
|
|
56
|
-
'fixed inset-x-0 bottom-0
|
|
66
|
+
'fixed inset-x-0 bottom-0 flex min-h-[50vh] max-h-[70vh] w-full translate-x-0 translate-y-0 flex-col gap-4 overflow-y-auto rounded-t-2xl border-t bg-card p-6 shadow-lg',
|
|
67
|
+
elevated ? 'z-modal-elevated' : 'z-modal',
|
|
57
68
|
'sm:inset-auto sm:left-1/2 sm:top-1/2 sm:min-h-0 sm:h-auto sm:w-full sm:max-w-lg sm:max-h-[90vh] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border',
|
|
58
69
|
'focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
59
70
|
className,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Mail } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Input, type InputProps } from './input'
|
|
7
|
+
|
|
8
|
+
export type EmailInputProps = Omit<InputProps, 'leftIcon' | 'type'> & {
|
|
9
|
+
/** Show the leading Mail icon. Defaults to `true` per Figma `Text Input [1.1]` Email variant. */
|
|
10
|
+
showIcon?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Email input matching Figma `Text Input [1.1]` (node `266:5251`) **Email** variant — a thin
|
|
15
|
+
* `Input` wrapper that hardcodes `type="email"`, `inputMode="email"`, `autoComplete="email"`,
|
|
16
|
+
* and renders a leading `Mail` icon by default. Placeholder defaults to an auto-translated
|
|
17
|
+
* `ui.inputs.emailInput.placeholder`.
|
|
18
|
+
*/
|
|
19
|
+
export const EmailInput = React.forwardRef<HTMLInputElement, EmailInputProps>(
|
|
20
|
+
({ showIcon = true, placeholder, ...props }, ref) => {
|
|
21
|
+
const t = useT()
|
|
22
|
+
const resolvedPlaceholder = placeholder ?? t('ui.inputs.emailInput.placeholder', 'name@example.com')
|
|
23
|
+
return (
|
|
24
|
+
<Input
|
|
25
|
+
ref={ref}
|
|
26
|
+
type="email"
|
|
27
|
+
inputMode="email"
|
|
28
|
+
autoComplete="email"
|
|
29
|
+
placeholder={resolvedPlaceholder}
|
|
30
|
+
leftIcon={showIcon ? <Mail aria-hidden="true" /> : undefined}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
EmailInput.displayName = 'EmailInput'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Eye, EyeOff, Lock } from 'lucide-react'
|
|
5
|
+
import type { VariantProps } from 'class-variance-authority'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
|
+
import { inputWrapperVariants, inputElementVariants } from './input'
|
|
9
|
+
|
|
10
|
+
export type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &
|
|
11
|
+
VariantProps<typeof inputWrapperVariants> & {
|
|
12
|
+
/** Render the leading lock icon per Figma `Text Input [1.1]` Password variant. Defaults to `true`. */
|
|
13
|
+
showLockIcon?: boolean
|
|
14
|
+
/** Allow the user to toggle reveal (eye / eye-off). Defaults to `true`. */
|
|
15
|
+
revealable?: boolean
|
|
16
|
+
/** Optional controlled reveal state (otherwise managed internally). */
|
|
17
|
+
revealed?: boolean
|
|
18
|
+
/** Called when the reveal state changes. */
|
|
19
|
+
onRevealedChange?: (next: boolean) => void
|
|
20
|
+
/** Optional className on the wrapper. */
|
|
21
|
+
className?: string
|
|
22
|
+
/** Optional className on the inner `<input>`. */
|
|
23
|
+
inputClassName?: string
|
|
24
|
+
/** Translated aria-label for the reveal button when password is hidden. */
|
|
25
|
+
showLabel?: string
|
|
26
|
+
/** Translated aria-label for the reveal button when password is shown. */
|
|
27
|
+
hideLabel?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Password input matching Figma `Text Input [1.1]` (node `266:5251`) **Password** variant — a
|
|
32
|
+
* trailing `Eye` / `EyeOff` toggle that switches the inner `<input>`'s `type` between `"password"`
|
|
33
|
+
* (default) and `"text"`. The toggle is a proper `<button>` (focusable, screen-reader labelled).
|
|
34
|
+
*
|
|
35
|
+
* Internally managed reveal state when `revealed` / `onRevealedChange` are not passed. Otherwise
|
|
36
|
+
* the consumer owns the toggle state (useful for "show password" master toggle on login screens).
|
|
37
|
+
*/
|
|
38
|
+
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
39
|
+
(
|
|
40
|
+
{
|
|
41
|
+
className,
|
|
42
|
+
inputClassName,
|
|
43
|
+
size,
|
|
44
|
+
showLockIcon = true,
|
|
45
|
+
revealable = true,
|
|
46
|
+
revealed: revealedProp,
|
|
47
|
+
onRevealedChange,
|
|
48
|
+
showLabel,
|
|
49
|
+
hideLabel,
|
|
50
|
+
disabled,
|
|
51
|
+
...props
|
|
52
|
+
},
|
|
53
|
+
ref,
|
|
54
|
+
) => {
|
|
55
|
+
const t = useT()
|
|
56
|
+
const resolvedShowLabel = showLabel ?? t('ui.inputs.passwordInput.show', 'Show password')
|
|
57
|
+
const resolvedHideLabel = hideLabel ?? t('ui.inputs.passwordInput.hide', 'Hide password')
|
|
58
|
+
const [internalRevealed, setInternalRevealed] = React.useState(false)
|
|
59
|
+
const isControlled = revealedProp !== undefined
|
|
60
|
+
const revealed = isControlled ? revealedProp! : internalRevealed
|
|
61
|
+
|
|
62
|
+
const toggleRevealed = React.useCallback(() => {
|
|
63
|
+
const next = !revealed
|
|
64
|
+
if (!isControlled) setInternalRevealed(next)
|
|
65
|
+
onRevealedChange?.(next)
|
|
66
|
+
}, [revealed, isControlled, onRevealedChange])
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn(inputWrapperVariants({ size }), className)} data-slot="password-input-wrapper">
|
|
70
|
+
{showLockIcon ? (
|
|
71
|
+
<Lock className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
72
|
+
) : null}
|
|
73
|
+
<input
|
|
74
|
+
ref={ref}
|
|
75
|
+
type={revealed ? 'text' : 'password'}
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
className={cn(inputElementVariants({ size }), inputClassName)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
{revealable ? (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={toggleRevealed}
|
|
84
|
+
aria-label={revealed ? resolvedHideLabel : resolvedShowLabel}
|
|
85
|
+
aria-pressed={revealed}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
className="flex shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50"
|
|
88
|
+
>
|
|
89
|
+
{revealed ? (
|
|
90
|
+
<EyeOff className="size-4" aria-hidden="true" />
|
|
91
|
+
) : (
|
|
92
|
+
<Eye className="size-4" aria-hidden="true" />
|
|
93
|
+
)}
|
|
94
|
+
</button>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
PasswordInput.displayName = 'PasswordInput'
|