@open-mercato/ui 0.6.1-develop.3278.1.a4d26475e4 → 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 (72) 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/FilterBar.js +11 -14
  8. package/dist/backend/FilterBar.js.map +2 -2
  9. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  10. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  11. package/dist/backend/inputs/ComboboxInput.js +11 -7
  12. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  13. package/dist/backend/inputs/EventPatternInput.js +5 -2
  14. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  15. package/dist/backend/inputs/EventSelect.js +10 -4
  16. package/dist/backend/inputs/EventSelect.js.map +2 -2
  17. package/dist/backend/inputs/LookupSelect.js +51 -47
  18. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  19. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  20. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  21. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  22. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  23. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  24. package/dist/backend/utils/serverErrors.js +6 -2
  25. package/dist/backend/utils/serverErrors.js.map +2 -2
  26. package/dist/portal/utils/nav.js +17 -0
  27. package/dist/portal/utils/nav.js.map +2 -2
  28. package/dist/primitives/amount-input.js +128 -0
  29. package/dist/primitives/amount-input.js.map +7 -0
  30. package/dist/primitives/button-input.js +47 -0
  31. package/dist/primitives/button-input.js.map +7 -0
  32. package/dist/primitives/card-input.js +133 -0
  33. package/dist/primitives/card-input.js.map +7 -0
  34. package/dist/primitives/email-input.js +29 -0
  35. package/dist/primitives/email-input.js.map +7 -0
  36. package/dist/primitives/password-input.js +64 -0
  37. package/dist/primitives/password-input.js.map +7 -0
  38. package/dist/primitives/search-input.js +57 -0
  39. package/dist/primitives/search-input.js.map +7 -0
  40. package/dist/primitives/select.js +43 -5
  41. package/dist/primitives/select.js.map +2 -2
  42. package/dist/primitives/website-input.js +56 -0
  43. package/dist/primitives/website-input.js.map +7 -0
  44. package/package.json +3 -3
  45. package/src/backend/AppShell.tsx +7 -30
  46. package/src/backend/CrudForm.tsx +17 -0
  47. package/src/backend/FilterBar.tsx +5 -6
  48. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  49. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  50. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  51. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  52. package/src/backend/inputs/EventSelect.tsx +13 -4
  53. package/src/backend/inputs/LookupSelect.tsx +75 -53
  54. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  55. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  56. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  57. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  58. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  59. package/src/backend/utils/serverErrors.ts +15 -8
  60. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  61. package/src/portal/utils/nav.ts +23 -1
  62. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  63. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  64. package/src/primitives/__tests__/zindex-overlay.test.tsx +4 -0
  65. package/src/primitives/amount-input.tsx +184 -0
  66. package/src/primitives/button-input.tsx +68 -0
  67. package/src/primitives/card-input.tsx +193 -0
  68. package/src/primitives/email-input.tsx +36 -0
  69. package/src/primitives/password-input.tsx +100 -0
  70. package/src/primitives/search-input.tsx +87 -0
  71. package/src/primitives/select.tsx +98 -11
  72. package/src/primitives/website-input.tsx +72 -0
@@ -3,6 +3,7 @@ import { FileCode, Globe, Lock } from 'lucide-react'
3
3
  import { type CrudField } from '../CrudForm'
4
4
  import { IconButton } from '../../primitives/icon-button'
5
5
  import { Input } from '../../primitives/input'
6
+ import { EmailInput } from '../../primitives/email-input'
6
7
  import { Label } from '../../primitives/label'
7
8
  import {
8
9
  Select,
@@ -191,9 +192,8 @@ function ComposeModeFields({ compose }: ComposeProps) {
191
192
  {compose.visibility === 'public' ? (
192
193
  <>
193
194
  <Label htmlFor="messages-compose-external-email">{compose.t('messages.externalEmail', 'External email')}</Label>
194
- <Input
195
+ <EmailInput
195
196
  id="messages-compose-external-email"
196
- type="email"
197
197
  value={compose.externalEmail}
198
198
  onChange={(event) => compose.setExternalEmail(event.target.value)}
199
199
  placeholder={compose.t('messages.placeholders.externalEmail', 'name@example.com')}
@@ -124,12 +124,19 @@ export function normalizeCrudServerError(err: unknown): NormalizedCrudServerErro
124
124
 
125
125
  if (typeof current !== 'object') continue
126
126
 
127
+ const errorField = (current as any).error
128
+ const nestedErrorMessage =
129
+ errorField && typeof errorField === 'object' &&
130
+ typeof (errorField as any).message === 'string' &&
131
+ (errorField as any).message.trim()
132
+ ? ((errorField as any).message as string)
133
+ : undefined
127
134
  const candidateMessage =
128
- typeof (current as any).error === 'string'
129
- ? (current as any).error
135
+ typeof errorField === 'string'
136
+ ? errorField
130
137
  : typeof (current as any).message === 'string'
131
138
  ? (current as any).message
132
- : undefined
139
+ : nestedErrorMessage
133
140
  if (candidateMessage && !message) message = candidateMessage
134
141
 
135
142
  for (const key of JSON_FIELD_KEYS) {
@@ -266,12 +273,12 @@ export async function raiseCrudError(res: Response, fallbackMessage?: string): P
266
273
 
267
274
  if (parsed && typeof parsed === 'object') {
268
275
  const data = parsed as Record<string, unknown>
276
+ const normalized = normalizeCrudServerError(data)
277
+ const normalizedMessage = normalized.message?.trim()
269
278
  const rawMessage =
270
- typeof data.error === 'string' && data.error.trim()
271
- ? data.error.trim()
272
- : typeof data.message === 'string' && data.message.trim()
273
- ? data.message.trim()
274
- : fallbackMessage ?? `Request failed (${res.status})`
279
+ normalizedMessage && normalizedMessage.length > 0
280
+ ? normalizedMessage
281
+ : fallbackMessage ?? `Request failed (${res.status})`
275
282
  const message = parseServerMessage(rawMessage)
276
283
  throw buildHttpError(message, {
277
284
  ...data,
@@ -135,6 +135,86 @@ describe('buildPortalNav', () => {
135
135
  expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
136
136
  })
137
137
 
138
+ it('dedupes manifest entries that share the same portal pattern', () => {
139
+ const routes: FrontendRouteManifestEntry[] = [
140
+ makeRoute({
141
+ moduleId: 'core',
142
+ pattern: '/[orgSlug]/portal/dashboard',
143
+ nav: { label: 'Dashboard', group: 'main', order: 10 },
144
+ }),
145
+ makeRoute({
146
+ moduleId: 'app',
147
+ pattern: '/[orgSlug]/portal/dashboard',
148
+ nav: { label: 'Dashboard', group: 'main', order: 10 },
149
+ }),
150
+ ]
151
+
152
+ const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
153
+ const main = groups.find((g) => g.id === 'main')!
154
+ expect(main.items).toHaveLength(1)
155
+ const ids = main.items.map((i) => i.id)
156
+ expect(new Set(ids).size).toBe(ids.length)
157
+ })
158
+
159
+ it('prefers the override with non-empty requireCustomerFeatures over a broad fallback', () => {
160
+ const routes: FrontendRouteManifestEntry[] = [
161
+ makeRoute({
162
+ moduleId: 'core',
163
+ pattern: '/[orgSlug]/portal/dashboard',
164
+ nav: { label: 'Dashboard', group: 'main' },
165
+ }),
166
+ makeRoute({
167
+ moduleId: 'app',
168
+ pattern: '/[orgSlug]/portal/dashboard',
169
+ requireCustomerFeatures: ['portal.dashboard.view'],
170
+ nav: { label: 'Dashboard', group: 'main' },
171
+ }),
172
+ ]
173
+
174
+ const without = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
175
+ expect(without).toEqual([])
176
+
177
+ const withGrant = buildPortalNav({
178
+ routes,
179
+ orgSlug: 'my-org',
180
+ grantedFeatures: ['portal.dashboard.view'],
181
+ })
182
+ expect(withGrant).toEqual([
183
+ { id: 'main', items: [expect.objectContaining({ label: 'Dashboard' })] },
184
+ ])
185
+ })
186
+
187
+ it('prefers the override with the longer requireCustomerFeatures list', () => {
188
+ const routes: FrontendRouteManifestEntry[] = [
189
+ makeRoute({
190
+ moduleId: 'core',
191
+ pattern: '/[orgSlug]/portal/orders',
192
+ requireCustomerFeatures: ['portal.orders.view'],
193
+ nav: { label: 'Orders', group: 'main' },
194
+ }),
195
+ makeRoute({
196
+ moduleId: 'app',
197
+ pattern: '/[orgSlug]/portal/orders',
198
+ requireCustomerFeatures: ['portal.orders.view', 'portal.orders.manage'],
199
+ nav: { label: 'Orders', group: 'main' },
200
+ }),
201
+ ]
202
+
203
+ const partial = buildPortalNav({
204
+ routes,
205
+ orgSlug: 'my-org',
206
+ grantedFeatures: ['portal.orders.view'],
207
+ })
208
+ expect(partial).toEqual([])
209
+
210
+ const full = buildPortalNav({
211
+ routes,
212
+ orgSlug: 'my-org',
213
+ grantedFeatures: ['portal.orders.view', 'portal.orders.manage'],
214
+ })
215
+ expect(full[0].items[0].label).toBe('Orders')
216
+ })
217
+
138
218
  it('sorts items by order then label', () => {
139
219
  const routes: FrontendRouteManifestEntry[] = [
140
220
  makeRoute({
@@ -46,6 +46,20 @@ function pickGroup(group: unknown): PortalNavGroupId {
46
46
  return 'main'
47
47
  }
48
48
 
49
+ function pickPreferredRoute(
50
+ existing: FrontendRouteManifestEntry,
51
+ candidate: FrontendRouteManifestEntry,
52
+ ): FrontendRouteManifestEntry {
53
+ const existingFeatures = existing.requireCustomerFeatures ?? []
54
+ const candidateFeatures = candidate.requireCustomerFeatures ?? []
55
+ if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate
56
+ if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing
57
+ if (existingFeatures.length !== candidateFeatures.length) {
58
+ return candidateFeatures.length > existingFeatures.length ? candidate : existing
59
+ }
60
+ return existing
61
+ }
62
+
49
63
  /**
50
64
  * Build the portal sidebar from the frontend route manifest.
51
65
  *
@@ -66,19 +80,27 @@ export function buildPortalNav({
66
80
  const mainItems: PortalNavItem[] = []
67
81
  const accountItems: PortalNavItem[] = []
68
82
 
83
+ const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()
69
84
  for (const route of routes) {
70
85
  const pattern = route.pattern ?? route.path
71
86
  if (!isPortalPattern(pattern)) continue
72
87
  if (route.navHidden) continue
73
88
  const nav = route.nav
74
89
  if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue
90
+ const existing = dedupedByPattern.get(pattern)
91
+ dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)
92
+ }
93
+
94
+ for (const route of dedupedByPattern.values()) {
95
+ const pattern = (route.pattern ?? route.path) as string
96
+ const nav = route.nav!
75
97
 
76
98
  const requireFeatures = route.requireCustomerFeatures ?? []
77
99
  if (!isPortalAdmin && requireFeatures.length) {
78
100
  if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
79
101
  }
80
102
 
81
- const href = resolveHref(pattern as string, orgSlug)
103
+ const href = resolveHref(pattern, orgSlug)
82
104
  if (!hasNoUnresolvedParams(href)) continue
83
105
 
84
106
  const group = pickGroup(nav.group)
@@ -0,0 +1,93 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
4
+ useT: () => (_key: string, fallback: string) => fallback,
5
+ }))
6
+
7
+ import * as React from 'react'
8
+ import { fireEvent, render, screen } from '@testing-library/react'
9
+ import { PasswordInput } from '../password-input'
10
+
11
+ describe('PasswordInput primitive', () => {
12
+ it('renders the inner input as type="password" by default', () => {
13
+ const { container } = render(<PasswordInput defaultValue="secret" />)
14
+ const input = container.querySelector('input') as HTMLInputElement
15
+ expect(input).not.toBeNull()
16
+ expect(input.type).toBe('password')
17
+ })
18
+
19
+ it('toggles the input type when the reveal button is clicked', () => {
20
+ const { container } = render(<PasswordInput defaultValue="secret" />)
21
+ const input = container.querySelector('input') as HTMLInputElement
22
+ const toggle = screen.getByRole('button', { name: 'Show password' })
23
+ expect(input.type).toBe('password')
24
+ expect(toggle).toHaveAttribute('aria-pressed', 'false')
25
+
26
+ fireEvent.click(toggle)
27
+
28
+ expect(input.type).toBe('text')
29
+ expect(screen.getByRole('button', { name: 'Hide password' })).toHaveAttribute('aria-pressed', 'true')
30
+ })
31
+
32
+ it('respects the controlled reveal state via revealed prop', () => {
33
+ const onRevealedChange = jest.fn()
34
+ const { container, rerender } = render(
35
+ <PasswordInput defaultValue="secret" revealed={false} onRevealedChange={onRevealedChange} />,
36
+ )
37
+ const input = container.querySelector('input') as HTMLInputElement
38
+ expect(input.type).toBe('password')
39
+
40
+ fireEvent.click(screen.getByRole('button', { name: 'Show password' }))
41
+ expect(onRevealedChange).toHaveBeenCalledWith(true)
42
+ expect(input.type).toBe('password')
43
+
44
+ rerender(<PasswordInput defaultValue="secret" revealed onRevealedChange={onRevealedChange} />)
45
+ expect(input.type).toBe('text')
46
+ })
47
+
48
+ it('hides the reveal toggle when revealable is false', () => {
49
+ render(<PasswordInput defaultValue="secret" revealable={false} />)
50
+ expect(screen.queryByRole('button', { name: /show password|hide password/i })).toBeNull()
51
+ })
52
+
53
+ it('hides the leading lock icon when showLockIcon is false', () => {
54
+ const { container } = render(<PasswordInput showLockIcon={false} defaultValue="secret" />)
55
+ expect(container.querySelector('svg.lucide-lock')).toBeNull()
56
+ })
57
+
58
+ it('forwards consumer-supplied autoComplete to the input element', () => {
59
+ const { container } = render(
60
+ <PasswordInput defaultValue="secret" autoComplete="new-password" />,
61
+ )
62
+ const input = container.querySelector('input') as HTMLInputElement
63
+ expect(input).toHaveAttribute('autocomplete', 'new-password')
64
+ })
65
+
66
+ it('does not set autoComplete when the consumer omits it (lets browser heuristics decide)', () => {
67
+ const { container } = render(<PasswordInput defaultValue="secret" />)
68
+ const input = container.querySelector('input') as HTMLInputElement
69
+ expect(input.getAttribute('autocomplete')).toBeNull()
70
+ })
71
+
72
+ it('forwards name and id to the input element', () => {
73
+ const { container } = render(<PasswordInput id="signup-password" name="password" />)
74
+ const input = container.querySelector('input') as HTMLInputElement
75
+ expect(input.id).toBe('signup-password')
76
+ expect(input.name).toBe('password')
77
+ })
78
+
79
+ it('disables both the input and the reveal toggle when disabled', () => {
80
+ const { container } = render(<PasswordInput defaultValue="secret" disabled />)
81
+ const input = container.querySelector('input') as HTMLInputElement
82
+ const toggle = screen.getByRole('button', { name: 'Show password' }) as HTMLButtonElement
83
+ expect(input.disabled).toBe(true)
84
+ expect(toggle.disabled).toBe(true)
85
+ })
86
+
87
+ it('forwards refs to the underlying input element', () => {
88
+ const ref = React.createRef<HTMLInputElement>()
89
+ render(<PasswordInput ref={ref} defaultValue="secret" />)
90
+ expect(ref.current).not.toBeNull()
91
+ expect(ref.current?.tagName).toBe('INPUT')
92
+ })
93
+ })
@@ -0,0 +1,95 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
4
+ useT: () => (_key: string, fallback: string) => fallback,
5
+ }))
6
+
7
+ import * as React from 'react'
8
+ import { fireEvent, render, screen } from '@testing-library/react'
9
+ import { SearchInput } from '../search-input'
10
+
11
+ function Harness(initial: string, props: Partial<React.ComponentProps<typeof SearchInput>> = {}) {
12
+ const { onChange: consumerOnChange, ...rest } = props
13
+ function Wrapped() {
14
+ const [value, setValue] = React.useState(initial)
15
+ return (
16
+ <SearchInput
17
+ {...rest}
18
+ value={value}
19
+ onChange={(next) => {
20
+ setValue(next)
21
+ consumerOnChange?.(next)
22
+ }}
23
+ />
24
+ )
25
+ }
26
+ return render(<Wrapped />)
27
+ }
28
+
29
+ describe('SearchInput primitive', () => {
30
+ it('renders an input with type="search"', () => {
31
+ const { container } = Harness('')
32
+ const input = container.querySelector('input') as HTMLInputElement
33
+ expect(input).not.toBeNull()
34
+ expect(input.type).toBe('search')
35
+ })
36
+
37
+ it('exposes the searchbox ARIA role (matches Playwright getByRole expectations)', () => {
38
+ Harness('')
39
+ expect(screen.getByRole('searchbox')).toBeInTheDocument()
40
+ })
41
+
42
+ it('uses the translated placeholder fallback', () => {
43
+ Harness('')
44
+ expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument()
45
+ })
46
+
47
+ it('honors a consumer-supplied placeholder over the i18n fallback', () => {
48
+ Harness('', { placeholder: 'Find a customer' })
49
+ expect(screen.getByPlaceholderText('Find a customer')).toBeInTheDocument()
50
+ })
51
+
52
+ it('does not render the clear button when value is empty', () => {
53
+ Harness('')
54
+ expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
55
+ })
56
+
57
+ it('renders the clear button when value is non-empty', () => {
58
+ Harness('jan')
59
+ expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
60
+ })
61
+
62
+ it('clicking the clear button calls onChange("") when no onClear is supplied', () => {
63
+ const onChange = jest.fn()
64
+ Harness('jan', { onChange })
65
+ fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
66
+ expect(onChange).toHaveBeenCalledWith('')
67
+ })
68
+
69
+ it('clicking the clear button calls onClear instead of onChange when onClear is supplied', () => {
70
+ const onChange = jest.fn()
71
+ const onClear = jest.fn()
72
+ Harness('jan', { onChange, onClear })
73
+ fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
74
+ expect(onClear).toHaveBeenCalledTimes(1)
75
+ expect(onChange).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it('hides the clear button when clearable=false even with non-empty value', () => {
79
+ Harness('jan', { clearable: false })
80
+ expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
81
+ })
82
+
83
+ it('hides the clear button when disabled', () => {
84
+ Harness('jan', { disabled: true })
85
+ expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
86
+ })
87
+
88
+ it('forwards typing to onChange', () => {
89
+ const onChange = jest.fn()
90
+ Harness('', { onChange })
91
+ const input = screen.getByRole('searchbox') as HTMLInputElement
92
+ fireEvent.change(input, { target: { value: 'kowalski' } })
93
+ expect(onChange).toHaveBeenCalledWith('kowalski')
94
+ })
95
+ })
@@ -1,3 +1,7 @@
1
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
2
+ useT: () => (_key: string, fallback: string) => fallback,
3
+ }))
4
+
1
5
  import * as React from 'react'
2
6
  import * as fs from 'fs'
3
7
  import * as path from 'path'
@@ -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'