@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.
Files changed (131) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +149 -53
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/FilterBar.js +11 -14
  10. package/dist/backend/FilterBar.js.map +2 -2
  11. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  12. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  13. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  14. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  15. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  16. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  17. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  18. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  19. package/dist/backend/filters/FilterEmptyState.js +41 -0
  20. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  21. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  22. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  23. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  24. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  25. package/dist/backend/filters/QuickFilters.js +41 -0
  26. package/dist/backend/filters/QuickFilters.js.map +7 -0
  27. package/dist/backend/filters/filterValidation.js +47 -0
  28. package/dist/backend/filters/filterValidation.js.map +7 -0
  29. package/dist/backend/filters/treeReducer.js +159 -0
  30. package/dist/backend/filters/treeReducer.js.map +7 -0
  31. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  32. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +11 -7
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/EventPatternInput.js +5 -2
  36. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  37. package/dist/backend/inputs/EventSelect.js +10 -4
  38. package/dist/backend/inputs/EventSelect.js.map +2 -2
  39. package/dist/backend/inputs/LookupSelect.js +51 -47
  40. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  41. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  42. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  43. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  44. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  45. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  46. package/dist/backend/utils/api.js +18 -1
  47. package/dist/backend/utils/api.js.map +2 -2
  48. package/dist/backend/utils/serverErrors.js +6 -2
  49. package/dist/backend/utils/serverErrors.js.map +2 -2
  50. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  51. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  52. package/dist/backend/utils/useCurrentUserId.js +31 -0
  53. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  54. package/dist/portal/utils/nav.js +17 -0
  55. package/dist/portal/utils/nav.js.map +2 -2
  56. package/dist/primitives/amount-input.js +128 -0
  57. package/dist/primitives/amount-input.js.map +7 -0
  58. package/dist/primitives/button-input.js +47 -0
  59. package/dist/primitives/button-input.js.map +7 -0
  60. package/dist/primitives/card-input.js +133 -0
  61. package/dist/primitives/card-input.js.map +7 -0
  62. package/dist/primitives/dialog.js +7 -5
  63. package/dist/primitives/dialog.js.map +2 -2
  64. package/dist/primitives/email-input.js +29 -0
  65. package/dist/primitives/email-input.js.map +7 -0
  66. package/dist/primitives/password-input.js +64 -0
  67. package/dist/primitives/password-input.js.map +7 -0
  68. package/dist/primitives/search-input.js +57 -0
  69. package/dist/primitives/search-input.js.map +7 -0
  70. package/dist/primitives/select.js +43 -5
  71. package/dist/primitives/select.js.map +2 -2
  72. package/dist/primitives/tag.js +4 -2
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/website-input.js +56 -0
  75. package/dist/primitives/website-input.js.map +7 -0
  76. package/package.json +3 -3
  77. package/src/backend/AppShell.tsx +7 -30
  78. package/src/backend/CrudForm.tsx +17 -0
  79. package/src/backend/DataTable.tsx +307 -63
  80. package/src/backend/FilterBar.tsx +5 -6
  81. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  82. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  83. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  84. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  85. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  86. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  87. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  88. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  89. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  90. package/src/backend/filters/QuickFilters.tsx +62 -0
  91. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  92. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  93. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  94. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  95. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  96. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  97. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  98. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  99. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  100. package/src/backend/filters/filterValidation.ts +61 -0
  101. package/src/backend/filters/treeReducer.ts +201 -0
  102. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  103. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  104. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  105. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  106. package/src/backend/inputs/EventSelect.tsx +13 -4
  107. package/src/backend/inputs/LookupSelect.tsx +75 -53
  108. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  109. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  110. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  111. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  112. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  113. package/src/backend/utils/api.ts +27 -2
  114. package/src/backend/utils/serverErrors.ts +15 -8
  115. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  116. package/src/backend/utils/useCurrentUserId.ts +40 -0
  117. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  118. package/src/portal/utils/nav.ts +23 -1
  119. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  120. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  121. package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
  122. package/src/primitives/amount-input.tsx +184 -0
  123. package/src/primitives/button-input.tsx +68 -0
  124. package/src/primitives/card-input.tsx +193 -0
  125. package/src/primitives/dialog.tsx +18 -7
  126. package/src/primitives/email-input.tsx +36 -0
  127. package/src/primitives/password-input.tsx +100 -0
  128. package/src/primitives/search-input.tsx +87 -0
  129. package/src/primitives/select.tsx +98 -11
  130. package/src/primitives/tag.tsx +3 -1
  131. 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
- >(({ className, ...props }, ref) => (
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 z-modal bg-black/50 backdrop-blur-sm transition-opacity data-[state=open]:animate-in data-[state=closed]:animate-out',
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
- >(({ className, children, ...props }, ref) => {
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 z-modal 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',
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'