@open-mercato/ui 0.6.7-develop.6661.1.0043ed1d03 → 0.6.7-develop.6670.1.4efa7961c6

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.
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { CrudCustomFieldRenderProps } from '../CrudForm'
5
+ import { FieldRegistry } from './registry'
6
+ import { PhoneNumberField, PHONE_COUNTRIES } from '../inputs/PhoneNumberField'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectItemLeading,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from '../../primitives/select'
16
+
17
+ type PhoneFieldConfig = {
18
+ defaultCountryIso2?: string
19
+ }
20
+
21
+ type PhoneFieldDef = PhoneFieldConfig & {
22
+ configJson?: PhoneFieldConfig
23
+ }
24
+
25
+ type PhoneFieldInputProps = CrudCustomFieldRenderProps & { def?: PhoneFieldDef }
26
+
27
+ // Sentinel used by the definition editor to represent "no fixed default"
28
+ // because Radix Select cannot use an empty string as an item value.
29
+ const AUTO_COUNTRY_VALUE = '__om_phone_auto__'
30
+
31
+ function PhoneFieldInput({ id, value, setValue, disabled, error, def }: PhoneFieldInputProps) {
32
+ const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)
33
+ return (
34
+ <PhoneNumberField
35
+ id={id}
36
+ value={stringValue}
37
+ onValueChange={(next) => setValue(next ?? undefined)}
38
+ disabled={disabled}
39
+ externalError={error ?? null}
40
+ defaultCountryIso2={def?.defaultCountryIso2}
41
+ />
42
+ )
43
+ }
44
+
45
+ function PhoneFieldDefEditor({
46
+ def,
47
+ onChange,
48
+ }: {
49
+ def: { configJson?: PhoneFieldConfig } | undefined
50
+ onChange: (patch: Partial<PhoneFieldConfig>) => void
51
+ }) {
52
+ const t = useT()
53
+ const selected = typeof def?.configJson?.defaultCountryIso2 === 'string' ? def.configJson.defaultCountryIso2 : ''
54
+ return (
55
+ <div className="mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3">
56
+ <div className="space-y-1">
57
+ <label className="text-xs font-medium text-muted-foreground">
58
+ {t('ui.customFields.phone.defaultCountry', 'Default country')}
59
+ </label>
60
+ <Select
61
+ value={selected || AUTO_COUNTRY_VALUE}
62
+ onValueChange={(next) => onChange({ defaultCountryIso2: next === AUTO_COUNTRY_VALUE ? undefined : next })}
63
+ >
64
+ <SelectTrigger size="sm">
65
+ <SelectValue />
66
+ </SelectTrigger>
67
+ <SelectContent align="start">
68
+ <SelectItem value={AUTO_COUNTRY_VALUE}>
69
+ {t('ui.customFields.phone.defaultCountryAuto', 'Auto-detect from value')}
70
+ </SelectItem>
71
+ {PHONE_COUNTRIES.map((country) => (
72
+ <SelectItem key={`${country.iso2}-${country.dialCode}`} value={country.iso2}>
73
+ <SelectItemLeading>
74
+ <span className="text-base leading-none">{country.flag}</span>
75
+ </SelectItemLeading>
76
+ <span className="flex-1 truncate">{country.label}</span>
77
+ <span className="ml-2 text-xs text-muted-foreground tabular-nums">{country.dialCode}</span>
78
+ </SelectItem>
79
+ ))}
80
+ </SelectContent>
81
+ </Select>
82
+ <p className="text-xs text-muted-foreground">
83
+ {t(
84
+ 'ui.customFields.phone.defaultCountryHint',
85
+ 'Pre-selects a country in the phone editor when the field is empty.',
86
+ )}
87
+ </p>
88
+ </div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ FieldRegistry.register('phone', {
94
+ input: (props) => <PhoneFieldInput {...props} />,
95
+ defEditor: (props) => <PhoneFieldDefEditor {...props} />,
96
+ })
97
+
98
+ export {}
@@ -2,6 +2,7 @@
2
2
  // This file can be regenerated by module generators. Keep imports side-effectful.
3
3
  import '@open-mercato/core/modules/attachments/fields/attachment'
4
4
  import '@open-mercato/core/modules/dictionaries/fields/dictionary'
5
+ import './phone'
5
6
 
6
7
  export function loadAll() {
7
8
  // No-op: imports above perform registration via side effects
@@ -21,6 +21,7 @@ import {
21
21
  Book,
22
22
  Bookmark,
23
23
  Box,
24
+ Boxes,
24
25
  Briefcase,
25
26
  BriefcaseBusiness,
26
27
  Building,
@@ -84,12 +85,14 @@ import {
84
85
  Mail,
85
86
  MailOpen,
86
87
  MapPin,
88
+ MapPinned,
87
89
  MessageCircle,
88
90
  MinusCircle,
89
91
  Notebook,
90
92
  Package,
91
93
  PackageCheck,
92
94
  PackagePlus,
95
+ PackageSearch,
93
96
  PackageX,
94
97
  PauseCircle,
95
98
  Percent,
@@ -141,6 +144,7 @@ import {
141
144
  Users,
142
145
  UserX,
143
146
  Wallet,
147
+ Warehouse,
144
148
  Webhook,
145
149
  Workflow,
146
150
  Wrench,
@@ -165,6 +169,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
165
169
  'book': Book,
166
170
  'bookmark': Bookmark,
167
171
  'box': Box,
172
+ 'boxes': Boxes,
168
173
  'briefcase': Briefcase,
169
174
  'briefcase-business': BriefcaseBusiness,
170
175
  'building': Building,
@@ -228,12 +233,14 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
228
233
  'mail': Mail,
229
234
  'mail-open': MailOpen,
230
235
  'map-pin': MapPin,
236
+ 'map-pinned': MapPinned,
231
237
  'message-circle': MessageCircle,
232
238
  'minus-circle': MinusCircle,
233
239
  'notebook': Notebook,
234
240
  'package': Package,
235
241
  'package-check': PackageCheck,
236
242
  'package-plus': PackagePlus,
243
+ 'package-search': PackageSearch,
237
244
  'package-x': PackageX,
238
245
  'pause-circle': PauseCircle,
239
246
  'percent': Percent,
@@ -285,6 +292,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
285
292
  'user-x': UserX,
286
293
  'users': Users,
287
294
  'wallet': Wallet,
295
+ 'warehouse': Warehouse,
288
296
  'webhook': Webhook,
289
297
  'workflow': Workflow,
290
298
  'wrench': Wrench,
@@ -30,35 +30,316 @@ export type PhoneCountry = {
30
30
  }
31
31
 
32
32
  /**
33
- * Static country list focused on EU + Americas markets where Open Mercato
34
- * currently operates. Extend as needed; longest dial codes MUST appear before
35
- * their shorter ancestors (e.g. `+380` before `+38`) to keep prefix matching
36
- * deterministic. The default fallback is the first entry.
33
+ * Emoji flag for an ISO 3166-1 alpha-2 code, assembled from the two regional
34
+ * indicator symbols. Deriving it keeps the (large) dictionary free of hand-typed
35
+ * emoji and guarantees the flag always matches the code.
37
36
  */
38
- export const PHONE_COUNTRIES: PhoneCountry[] = [
39
- { iso2: 'US', dialCode: '+1', label: 'United States', flag: '🇺🇸' },
40
- { iso2: 'CA', dialCode: '+1', label: 'Canada', flag: '🇨🇦' },
41
- { iso2: 'GB', dialCode: '+44', label: 'United Kingdom', flag: '🇬🇧' },
42
- { iso2: 'PL', dialCode: '+48', label: 'Poland', flag: '🇵🇱' },
43
- { iso2: 'DE', dialCode: '+49', label: 'Germany', flag: '🇩🇪' },
44
- { iso2: 'FR', dialCode: '+33', label: 'France', flag: '🇫🇷' },
45
- { iso2: 'ES', dialCode: '+34', label: 'Spain', flag: '🇪🇸' },
46
- { iso2: 'IT', dialCode: '+39', label: 'Italy', flag: '🇮🇹' },
47
- { iso2: 'NL', dialCode: '+31', label: 'Netherlands', flag: '🇳🇱' },
48
- { iso2: 'SE', dialCode: '+46', label: 'Sweden', flag: '🇸🇪' },
49
- { iso2: 'AT', dialCode: '+43', label: 'Austria', flag: '🇦🇹' },
50
- { iso2: 'CH', dialCode: '+41', label: 'Switzerland', flag: '🇨🇭' },
51
- { iso2: 'PT', dialCode: '+351', label: 'Portugal', flag: '🇵🇹' },
52
- { iso2: 'CZ', dialCode: '+420', label: 'Czechia', flag: '🇨🇿' },
53
- { iso2: 'RO', dialCode: '+40', label: 'Romania', flag: '🇷🇴' },
54
- { iso2: 'UA', dialCode: '+380', label: 'Ukraine', flag: '🇺🇦' },
37
+ function iso2ToFlagEmoji(iso2: string): string {
38
+ const letters = iso2.toUpperCase().replace(/[^A-Z]/g, '')
39
+ if (letters.length !== 2) return ''
40
+ const base = 0x1f1e6
41
+ return String.fromCodePoint(
42
+ base + letters.charCodeAt(0) - 65,
43
+ base + letters.charCodeAt(1) - 65,
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Complete static dictionary of geographic countries and territories that have
49
+ * an ISO 3166-1 alpha-2 code and an international calling code, based on the
50
+ * ITU / Wikipedia list of telephone country codes.
51
+ *
52
+ * Rules baked into this data:
53
+ * - Labels are English; flags are derived from the ISO code (`iso2ToFlagEmoji`).
54
+ * - Non-geographic / international service codes (`+800`, `+808`, `+870`,
55
+ * `+881`, `+882`, …) are intentionally excluded.
56
+ * - North American Numbering Plan territories carry their full `+1<NPA>` dial
57
+ * code (e.g. Bahamas `+1242`) so longest-prefix matching resolves them before
58
+ * the generic `+1`. The US and Canada BOTH use a bare `+1` and cannot be told
59
+ * apart from the prefix alone — auto-detection favours the US.
60
+ * - Several sovereign countries share a calling code with dependent territories
61
+ * (e.g. `+44` UK/Guernsey/Jersey/Isle of Man); `PRIMARY_DIAL_OWNERS` records
62
+ * the country that wins auto-detection for those shared codes.
63
+ */
64
+ const RAW_PHONE_COUNTRIES: Array<Omit<PhoneCountry, 'flag'>> = [
65
+ { iso2: 'US', dialCode: '+1', label: 'United States' },
66
+ { iso2: 'CA', dialCode: '+1', label: 'Canada' },
67
+ { iso2: 'AG', dialCode: '+1268', label: 'Antigua and Barbuda' },
68
+ { iso2: 'AI', dialCode: '+1264', label: 'Anguilla' },
69
+ { iso2: 'AS', dialCode: '+1684', label: 'American Samoa' },
70
+ { iso2: 'BB', dialCode: '+1246', label: 'Barbados' },
71
+ { iso2: 'BM', dialCode: '+1441', label: 'Bermuda' },
72
+ { iso2: 'BS', dialCode: '+1242', label: 'Bahamas' },
73
+ { iso2: 'DM', dialCode: '+1767', label: 'Dominica' },
74
+ { iso2: 'DO', dialCode: '+1809', label: 'Dominican Republic' },
75
+ { iso2: 'GD', dialCode: '+1473', label: 'Grenada' },
76
+ { iso2: 'GU', dialCode: '+1671', label: 'Guam' },
77
+ { iso2: 'JM', dialCode: '+1876', label: 'Jamaica' },
78
+ { iso2: 'KN', dialCode: '+1869', label: 'Saint Kitts and Nevis' },
79
+ { iso2: 'KY', dialCode: '+1345', label: 'Cayman Islands' },
80
+ { iso2: 'LC', dialCode: '+1758', label: 'Saint Lucia' },
81
+ { iso2: 'MP', dialCode: '+1670', label: 'Northern Mariana Islands' },
82
+ { iso2: 'MS', dialCode: '+1664', label: 'Montserrat' },
83
+ { iso2: 'PR', dialCode: '+1787', label: 'Puerto Rico' },
84
+ { iso2: 'SX', dialCode: '+1721', label: 'Sint Maarten' },
85
+ { iso2: 'TC', dialCode: '+1649', label: 'Turks and Caicos Islands' },
86
+ { iso2: 'TT', dialCode: '+1868', label: 'Trinidad and Tobago' },
87
+ { iso2: 'VC', dialCode: '+1784', label: 'Saint Vincent and the Grenadines' },
88
+ { iso2: 'VG', dialCode: '+1284', label: 'British Virgin Islands' },
89
+ { iso2: 'VI', dialCode: '+1340', label: 'U.S. Virgin Islands' },
90
+ { iso2: 'EG', dialCode: '+20', label: 'Egypt' },
91
+ { iso2: 'SS', dialCode: '+211', label: 'South Sudan' },
92
+ { iso2: 'MA', dialCode: '+212', label: 'Morocco' },
93
+ { iso2: 'EH', dialCode: '+212', label: 'Western Sahara' },
94
+ { iso2: 'DZ', dialCode: '+213', label: 'Algeria' },
95
+ { iso2: 'TN', dialCode: '+216', label: 'Tunisia' },
96
+ { iso2: 'LY', dialCode: '+218', label: 'Libya' },
97
+ { iso2: 'GM', dialCode: '+220', label: 'Gambia' },
98
+ { iso2: 'SN', dialCode: '+221', label: 'Senegal' },
99
+ { iso2: 'MR', dialCode: '+222', label: 'Mauritania' },
100
+ { iso2: 'ML', dialCode: '+223', label: 'Mali' },
101
+ { iso2: 'GN', dialCode: '+224', label: 'Guinea' },
102
+ { iso2: 'CI', dialCode: '+225', label: "Côte d'Ivoire" },
103
+ { iso2: 'BF', dialCode: '+226', label: 'Burkina Faso' },
104
+ { iso2: 'NE', dialCode: '+227', label: 'Niger' },
105
+ { iso2: 'TG', dialCode: '+228', label: 'Togo' },
106
+ { iso2: 'BJ', dialCode: '+229', label: 'Benin' },
107
+ { iso2: 'MU', dialCode: '+230', label: 'Mauritius' },
108
+ { iso2: 'LR', dialCode: '+231', label: 'Liberia' },
109
+ { iso2: 'SL', dialCode: '+232', label: 'Sierra Leone' },
110
+ { iso2: 'GH', dialCode: '+233', label: 'Ghana' },
111
+ { iso2: 'NG', dialCode: '+234', label: 'Nigeria' },
112
+ { iso2: 'TD', dialCode: '+235', label: 'Chad' },
113
+ { iso2: 'CF', dialCode: '+236', label: 'Central African Republic' },
114
+ { iso2: 'CM', dialCode: '+237', label: 'Cameroon' },
115
+ { iso2: 'CV', dialCode: '+238', label: 'Cape Verde' },
116
+ { iso2: 'ST', dialCode: '+239', label: 'São Tomé and Príncipe' },
117
+ { iso2: 'GQ', dialCode: '+240', label: 'Equatorial Guinea' },
118
+ { iso2: 'GA', dialCode: '+241', label: 'Gabon' },
119
+ { iso2: 'CG', dialCode: '+242', label: 'Republic of the Congo' },
120
+ { iso2: 'CD', dialCode: '+243', label: 'DR Congo' },
121
+ { iso2: 'AO', dialCode: '+244', label: 'Angola' },
122
+ { iso2: 'GW', dialCode: '+245', label: 'Guinea-Bissau' },
123
+ { iso2: 'IO', dialCode: '+246', label: 'British Indian Ocean Territory' },
124
+ { iso2: 'SC', dialCode: '+248', label: 'Seychelles' },
125
+ { iso2: 'SD', dialCode: '+249', label: 'Sudan' },
126
+ { iso2: 'RW', dialCode: '+250', label: 'Rwanda' },
127
+ { iso2: 'ET', dialCode: '+251', label: 'Ethiopia' },
128
+ { iso2: 'SO', dialCode: '+252', label: 'Somalia' },
129
+ { iso2: 'DJ', dialCode: '+253', label: 'Djibouti' },
130
+ { iso2: 'KE', dialCode: '+254', label: 'Kenya' },
131
+ { iso2: 'TZ', dialCode: '+255', label: 'Tanzania' },
132
+ { iso2: 'UG', dialCode: '+256', label: 'Uganda' },
133
+ { iso2: 'BI', dialCode: '+257', label: 'Burundi' },
134
+ { iso2: 'MZ', dialCode: '+258', label: 'Mozambique' },
135
+ { iso2: 'ZM', dialCode: '+260', label: 'Zambia' },
136
+ { iso2: 'MG', dialCode: '+261', label: 'Madagascar' },
137
+ { iso2: 'RE', dialCode: '+262', label: 'Réunion' },
138
+ { iso2: 'YT', dialCode: '+262', label: 'Mayotte' },
139
+ { iso2: 'ZW', dialCode: '+263', label: 'Zimbabwe' },
140
+ { iso2: 'NA', dialCode: '+264', label: 'Namibia' },
141
+ { iso2: 'MW', dialCode: '+265', label: 'Malawi' },
142
+ { iso2: 'LS', dialCode: '+266', label: 'Lesotho' },
143
+ { iso2: 'BW', dialCode: '+267', label: 'Botswana' },
144
+ { iso2: 'SZ', dialCode: '+268', label: 'Eswatini' },
145
+ { iso2: 'KM', dialCode: '+269', label: 'Comoros' },
146
+ { iso2: 'ZA', dialCode: '+27', label: 'South Africa' },
147
+ { iso2: 'SH', dialCode: '+290', label: 'Saint Helena' },
148
+ { iso2: 'ER', dialCode: '+291', label: 'Eritrea' },
149
+ { iso2: 'AW', dialCode: '+297', label: 'Aruba' },
150
+ { iso2: 'FO', dialCode: '+298', label: 'Faroe Islands' },
151
+ { iso2: 'GL', dialCode: '+299', label: 'Greenland' },
152
+ { iso2: 'GR', dialCode: '+30', label: 'Greece' },
153
+ { iso2: 'NL', dialCode: '+31', label: 'Netherlands' },
154
+ { iso2: 'BE', dialCode: '+32', label: 'Belgium' },
155
+ { iso2: 'FR', dialCode: '+33', label: 'France' },
156
+ { iso2: 'ES', dialCode: '+34', label: 'Spain' },
157
+ { iso2: 'GI', dialCode: '+350', label: 'Gibraltar' },
158
+ { iso2: 'PT', dialCode: '+351', label: 'Portugal' },
159
+ { iso2: 'LU', dialCode: '+352', label: 'Luxembourg' },
160
+ { iso2: 'IE', dialCode: '+353', label: 'Ireland' },
161
+ { iso2: 'IS', dialCode: '+354', label: 'Iceland' },
162
+ { iso2: 'AL', dialCode: '+355', label: 'Albania' },
163
+ { iso2: 'MT', dialCode: '+356', label: 'Malta' },
164
+ { iso2: 'CY', dialCode: '+357', label: 'Cyprus' },
165
+ { iso2: 'FI', dialCode: '+358', label: 'Finland' },
166
+ { iso2: 'AX', dialCode: '+358', label: 'Åland Islands' },
167
+ { iso2: 'BG', dialCode: '+359', label: 'Bulgaria' },
168
+ { iso2: 'HU', dialCode: '+36', label: 'Hungary' },
169
+ { iso2: 'LT', dialCode: '+370', label: 'Lithuania' },
170
+ { iso2: 'LV', dialCode: '+371', label: 'Latvia' },
171
+ { iso2: 'EE', dialCode: '+372', label: 'Estonia' },
172
+ { iso2: 'MD', dialCode: '+373', label: 'Moldova' },
173
+ { iso2: 'AM', dialCode: '+374', label: 'Armenia' },
174
+ { iso2: 'BY', dialCode: '+375', label: 'Belarus' },
175
+ { iso2: 'AD', dialCode: '+376', label: 'Andorra' },
176
+ { iso2: 'MC', dialCode: '+377', label: 'Monaco' },
177
+ { iso2: 'SM', dialCode: '+378', label: 'San Marino' },
178
+ { iso2: 'VA', dialCode: '+379', label: 'Vatican City' },
179
+ { iso2: 'UA', dialCode: '+380', label: 'Ukraine' },
180
+ { iso2: 'RS', dialCode: '+381', label: 'Serbia' },
181
+ { iso2: 'ME', dialCode: '+382', label: 'Montenegro' },
182
+ { iso2: 'XK', dialCode: '+383', label: 'Kosovo' },
183
+ { iso2: 'HR', dialCode: '+385', label: 'Croatia' },
184
+ { iso2: 'SI', dialCode: '+386', label: 'Slovenia' },
185
+ { iso2: 'BA', dialCode: '+387', label: 'Bosnia and Herzegovina' },
186
+ { iso2: 'MK', dialCode: '+389', label: 'North Macedonia' },
187
+ { iso2: 'IT', dialCode: '+39', label: 'Italy' },
188
+ { iso2: 'RO', dialCode: '+40', label: 'Romania' },
189
+ { iso2: 'CH', dialCode: '+41', label: 'Switzerland' },
190
+ { iso2: 'CZ', dialCode: '+420', label: 'Czechia' },
191
+ { iso2: 'SK', dialCode: '+421', label: 'Slovakia' },
192
+ { iso2: 'LI', dialCode: '+423', label: 'Liechtenstein' },
193
+ { iso2: 'AT', dialCode: '+43', label: 'Austria' },
194
+ { iso2: 'GB', dialCode: '+44', label: 'United Kingdom' },
195
+ { iso2: 'GG', dialCode: '+44', label: 'Guernsey' },
196
+ { iso2: 'JE', dialCode: '+44', label: 'Jersey' },
197
+ { iso2: 'IM', dialCode: '+44', label: 'Isle of Man' },
198
+ { iso2: 'DK', dialCode: '+45', label: 'Denmark' },
199
+ { iso2: 'SE', dialCode: '+46', label: 'Sweden' },
200
+ { iso2: 'NO', dialCode: '+47', label: 'Norway' },
201
+ { iso2: 'SJ', dialCode: '+47', label: 'Svalbard and Jan Mayen' },
202
+ { iso2: 'PL', dialCode: '+48', label: 'Poland' },
203
+ { iso2: 'DE', dialCode: '+49', label: 'Germany' },
204
+ { iso2: 'FK', dialCode: '+500', label: 'Falkland Islands' },
205
+ { iso2: 'BZ', dialCode: '+501', label: 'Belize' },
206
+ { iso2: 'GT', dialCode: '+502', label: 'Guatemala' },
207
+ { iso2: 'SV', dialCode: '+503', label: 'El Salvador' },
208
+ { iso2: 'HN', dialCode: '+504', label: 'Honduras' },
209
+ { iso2: 'NI', dialCode: '+505', label: 'Nicaragua' },
210
+ { iso2: 'CR', dialCode: '+506', label: 'Costa Rica' },
211
+ { iso2: 'PA', dialCode: '+507', label: 'Panama' },
212
+ { iso2: 'PM', dialCode: '+508', label: 'Saint Pierre and Miquelon' },
213
+ { iso2: 'HT', dialCode: '+509', label: 'Haiti' },
214
+ { iso2: 'PE', dialCode: '+51', label: 'Peru' },
215
+ { iso2: 'MX', dialCode: '+52', label: 'Mexico' },
216
+ { iso2: 'CU', dialCode: '+53', label: 'Cuba' },
217
+ { iso2: 'AR', dialCode: '+54', label: 'Argentina' },
218
+ { iso2: 'BR', dialCode: '+55', label: 'Brazil' },
219
+ { iso2: 'CL', dialCode: '+56', label: 'Chile' },
220
+ { iso2: 'CO', dialCode: '+57', label: 'Colombia' },
221
+ { iso2: 'VE', dialCode: '+58', label: 'Venezuela' },
222
+ { iso2: 'GP', dialCode: '+590', label: 'Guadeloupe' },
223
+ { iso2: 'BL', dialCode: '+590', label: 'Saint Barthélemy' },
224
+ { iso2: 'MF', dialCode: '+590', label: 'Saint Martin' },
225
+ { iso2: 'BO', dialCode: '+591', label: 'Bolivia' },
226
+ { iso2: 'GY', dialCode: '+592', label: 'Guyana' },
227
+ { iso2: 'EC', dialCode: '+593', label: 'Ecuador' },
228
+ { iso2: 'GF', dialCode: '+594', label: 'French Guiana' },
229
+ { iso2: 'PY', dialCode: '+595', label: 'Paraguay' },
230
+ { iso2: 'MQ', dialCode: '+596', label: 'Martinique' },
231
+ { iso2: 'SR', dialCode: '+597', label: 'Suriname' },
232
+ { iso2: 'UY', dialCode: '+598', label: 'Uruguay' },
233
+ { iso2: 'CW', dialCode: '+599', label: 'Curaçao' },
234
+ { iso2: 'BQ', dialCode: '+599', label: 'Caribbean Netherlands' },
235
+ { iso2: 'MY', dialCode: '+60', label: 'Malaysia' },
236
+ { iso2: 'AU', dialCode: '+61', label: 'Australia' },
237
+ { iso2: 'CX', dialCode: '+61', label: 'Christmas Island' },
238
+ { iso2: 'CC', dialCode: '+61', label: 'Cocos (Keeling) Islands' },
239
+ { iso2: 'ID', dialCode: '+62', label: 'Indonesia' },
240
+ { iso2: 'PH', dialCode: '+63', label: 'Philippines' },
241
+ { iso2: 'NZ', dialCode: '+64', label: 'New Zealand' },
242
+ { iso2: 'SG', dialCode: '+65', label: 'Singapore' },
243
+ { iso2: 'TH', dialCode: '+66', label: 'Thailand' },
244
+ { iso2: 'TL', dialCode: '+670', label: 'Timor-Leste' },
245
+ { iso2: 'NF', dialCode: '+672', label: 'Norfolk Island' },
246
+ { iso2: 'BN', dialCode: '+673', label: 'Brunei' },
247
+ { iso2: 'NR', dialCode: '+674', label: 'Nauru' },
248
+ { iso2: 'PG', dialCode: '+675', label: 'Papua New Guinea' },
249
+ { iso2: 'TO', dialCode: '+676', label: 'Tonga' },
250
+ { iso2: 'SB', dialCode: '+677', label: 'Solomon Islands' },
251
+ { iso2: 'VU', dialCode: '+678', label: 'Vanuatu' },
252
+ { iso2: 'FJ', dialCode: '+679', label: 'Fiji' },
253
+ { iso2: 'PW', dialCode: '+680', label: 'Palau' },
254
+ { iso2: 'WF', dialCode: '+681', label: 'Wallis and Futuna' },
255
+ { iso2: 'CK', dialCode: '+682', label: 'Cook Islands' },
256
+ { iso2: 'NU', dialCode: '+683', label: 'Niue' },
257
+ { iso2: 'WS', dialCode: '+685', label: 'Samoa' },
258
+ { iso2: 'KI', dialCode: '+686', label: 'Kiribati' },
259
+ { iso2: 'NC', dialCode: '+687', label: 'New Caledonia' },
260
+ { iso2: 'TV', dialCode: '+688', label: 'Tuvalu' },
261
+ { iso2: 'PF', dialCode: '+689', label: 'French Polynesia' },
262
+ { iso2: 'TK', dialCode: '+690', label: 'Tokelau' },
263
+ { iso2: 'FM', dialCode: '+691', label: 'Micronesia' },
264
+ { iso2: 'MH', dialCode: '+692', label: 'Marshall Islands' },
265
+ { iso2: 'RU', dialCode: '+7', label: 'Russia' },
266
+ { iso2: 'KZ', dialCode: '+7', label: 'Kazakhstan' },
267
+ { iso2: 'JP', dialCode: '+81', label: 'Japan' },
268
+ { iso2: 'KR', dialCode: '+82', label: 'South Korea' },
269
+ { iso2: 'VN', dialCode: '+84', label: 'Vietnam' },
270
+ { iso2: 'KP', dialCode: '+850', label: 'North Korea' },
271
+ { iso2: 'HK', dialCode: '+852', label: 'Hong Kong' },
272
+ { iso2: 'MO', dialCode: '+853', label: 'Macau' },
273
+ { iso2: 'KH', dialCode: '+855', label: 'Cambodia' },
274
+ { iso2: 'LA', dialCode: '+856', label: 'Laos' },
275
+ { iso2: 'CN', dialCode: '+86', label: 'China' },
276
+ { iso2: 'BD', dialCode: '+880', label: 'Bangladesh' },
277
+ { iso2: 'TW', dialCode: '+886', label: 'Taiwan' },
278
+ { iso2: 'TR', dialCode: '+90', label: 'Turkey' },
279
+ { iso2: 'IN', dialCode: '+91', label: 'India' },
280
+ { iso2: 'PK', dialCode: '+92', label: 'Pakistan' },
281
+ { iso2: 'AF', dialCode: '+93', label: 'Afghanistan' },
282
+ { iso2: 'LK', dialCode: '+94', label: 'Sri Lanka' },
283
+ { iso2: 'MM', dialCode: '+95', label: 'Myanmar' },
284
+ { iso2: 'MV', dialCode: '+960', label: 'Maldives' },
285
+ { iso2: 'LB', dialCode: '+961', label: 'Lebanon' },
286
+ { iso2: 'JO', dialCode: '+962', label: 'Jordan' },
287
+ { iso2: 'SY', dialCode: '+963', label: 'Syria' },
288
+ { iso2: 'IQ', dialCode: '+964', label: 'Iraq' },
289
+ { iso2: 'KW', dialCode: '+965', label: 'Kuwait' },
290
+ { iso2: 'SA', dialCode: '+966', label: 'Saudi Arabia' },
291
+ { iso2: 'YE', dialCode: '+967', label: 'Yemen' },
292
+ { iso2: 'OM', dialCode: '+968', label: 'Oman' },
293
+ { iso2: 'PS', dialCode: '+970', label: 'Palestine' },
294
+ { iso2: 'AE', dialCode: '+971', label: 'United Arab Emirates' },
295
+ { iso2: 'IL', dialCode: '+972', label: 'Israel' },
296
+ { iso2: 'BH', dialCode: '+973', label: 'Bahrain' },
297
+ { iso2: 'QA', dialCode: '+974', label: 'Qatar' },
298
+ { iso2: 'BT', dialCode: '+975', label: 'Bhutan' },
299
+ { iso2: 'MN', dialCode: '+976', label: 'Mongolia' },
300
+ { iso2: 'NP', dialCode: '+977', label: 'Nepal' },
301
+ { iso2: 'TJ', dialCode: '+992', label: 'Tajikistan' },
302
+ { iso2: 'TM', dialCode: '+993', label: 'Turkmenistan' },
303
+ { iso2: 'AZ', dialCode: '+994', label: 'Azerbaijan' },
304
+ { iso2: 'GE', dialCode: '+995', label: 'Georgia' },
305
+ { iso2: 'KG', dialCode: '+996', label: 'Kyrgyzstan' },
306
+ { iso2: 'UZ', dialCode: '+998', label: 'Uzbekistan' },
307
+ { iso2: 'IR', dialCode: '+98', label: 'Iran' },
55
308
  ]
56
309
 
57
- const DEFAULT_COUNTRY = PHONE_COUNTRIES[0]
58
- // Match longer prefixes first to avoid e.g. `+1` shadowing `+1234` if any.
59
- const COUNTRIES_BY_DIAL_LENGTH = [...PHONE_COUNTRIES].sort(
60
- (a, b) => b.dialCode.length - a.dialCode.length,
61
- )
310
+ export const PHONE_COUNTRIES: PhoneCountry[] = RAW_PHONE_COUNTRIES
311
+ .map((country) => ({ ...country, flag: iso2ToFlagEmoji(country.iso2) }))
312
+ .sort((a, b) => a.label.localeCompare(b.label, 'en', { sensitivity: 'base' }))
313
+
314
+ /**
315
+ * Sovereign/primary country that wins auto-detection for a calling code shared
316
+ * by several territories. US vs. Canada is the one irreducible ambiguity — both
317
+ * are `+1` — and it resolves to the US.
318
+ */
319
+ const PRIMARY_DIAL_OWNERS: Record<string, string> = {
320
+ '+1': 'US',
321
+ '+7': 'RU',
322
+ '+44': 'GB',
323
+ '+47': 'NO',
324
+ '+61': 'AU',
325
+ '+212': 'MA',
326
+ '+262': 'RE',
327
+ '+358': 'FI',
328
+ '+590': 'GP',
329
+ '+599': 'CW',
330
+ }
331
+
332
+ const DEFAULT_COUNTRY =
333
+ PHONE_COUNTRIES.find((country) => country.iso2 === 'US') ?? PHONE_COUNTRIES[0]
334
+
335
+ // Match longer prefixes first (e.g. `+1242` before `+1`); for equal-length codes
336
+ // shared by several territories, prefer the sovereign in `PRIMARY_DIAL_OWNERS`.
337
+ const COUNTRIES_BY_DIAL_LENGTH = [...PHONE_COUNTRIES].sort((a, b) => {
338
+ if (b.dialCode.length !== a.dialCode.length) return b.dialCode.length - a.dialCode.length
339
+ const aPrimary = PRIMARY_DIAL_OWNERS[a.dialCode] === a.iso2 ? 0 : 1
340
+ const bPrimary = PRIMARY_DIAL_OWNERS[b.dialCode] === b.iso2 ? 0 : 1
341
+ return aPrimary - bPrimary
342
+ })
62
343
 
63
344
  function findCountryByIso(iso2: string): PhoneCountry | undefined {
64
345
  return PHONE_COUNTRIES.find((c) => c.iso2 === iso2)
@@ -22,7 +22,7 @@ jest.mock('@open-mercato/shared/lib/phone', () => ({
22
22
 
23
23
  import * as React from 'react'
24
24
  import { fireEvent, render, screen } from '@testing-library/react'
25
- import { PhoneNumberField } from '../PhoneNumberField'
25
+ import { PHONE_COUNTRIES, PhoneNumberField } from '../PhoneNumberField'
26
26
 
27
27
  function PhoneFieldHarness(props: Partial<React.ComponentProps<typeof PhoneNumberField>>) {
28
28
  const [value, setValue] = React.useState<string | undefined>(undefined)
@@ -76,4 +76,74 @@ describe('PhoneNumberField', () => {
76
76
  expect(screen.queryByText('Use an international phone number.')).not.toBeInTheDocument()
77
77
  expect(input).toHaveAttribute('aria-invalid', 'true')
78
78
  })
79
+
80
+ it('honours a restricted country list via the countries prop', () => {
81
+ render(
82
+ <PhoneFieldHarness
83
+ value="+383 44 123 456"
84
+ countries={[{ iso2: 'PL', dialCode: '+48', label: 'Poland', flag: '🇵🇱' }]}
85
+ />,
86
+ )
87
+
88
+ // The value still parses against the full dictionary (Kosovo), but the
89
+ // picker only offers the restricted list.
90
+ expect(screen.getByText('+383')).toBeInTheDocument()
91
+ })
92
+ })
93
+
94
+ describe('PhoneNumberField country dictionary', () => {
95
+ it('exposes a complete, well-formed geographic dictionary', () => {
96
+ expect(PHONE_COUNTRIES.length).toBeGreaterThan(200)
97
+
98
+ for (const country of PHONE_COUNTRIES) {
99
+ expect(country.iso2).toMatch(/^[A-Z]{2}$/)
100
+ expect(country.dialCode).toMatch(/^\+\d+$/)
101
+ expect(country.label.length).toBeGreaterThan(0)
102
+ expect(country.flag.length).toBeGreaterThan(0)
103
+ }
104
+
105
+ const isoCodes = PHONE_COUNTRIES.map((country) => country.iso2)
106
+ expect(new Set(isoCodes).size).toBe(isoCodes.length)
107
+ })
108
+
109
+ it('includes representative codes from every numbering zone', () => {
110
+ const dialByIso = new Map(PHONE_COUNTRIES.map((country) => [country.iso2, country.dialCode]))
111
+ expect(dialByIso.get('XK')).toBe('+383')
112
+ expect(dialByIso.get('BS')).toBe('+1242')
113
+ expect(dialByIso.get('BR')).toBe('+55')
114
+ expect(dialByIso.get('AU')).toBe('+61')
115
+ expect(dialByIso.get('JP')).toBe('+81')
116
+ expect(dialByIso.get('TR')).toBe('+90')
117
+ expect(dialByIso.get('PS')).toBe('+970')
118
+ })
119
+
120
+ it('excludes non-geographic international service codes', () => {
121
+ for (const code of ['+800', '+808', '+870', '+881', '+882']) {
122
+ expect(PHONE_COUNTRIES.some((country) => country.dialCode === code)).toBe(false)
123
+ }
124
+ })
125
+
126
+ it('derives each flag emoji from the ISO code', () => {
127
+ expect(PHONE_COUNTRIES.find((country) => country.iso2 === 'US')?.flag).toBe('🇺🇸')
128
+ expect(PHONE_COUNTRIES.find((country) => country.iso2 === 'XK')?.flag).toBe('🇽🇰')
129
+ })
130
+ })
131
+
132
+ describe('PhoneNumberField initial country detection', () => {
133
+ const cases: Array<[string, string]> = [
134
+ ['+1242 555 0100', '+1242'],
135
+ ['+1 212 555 1234', '+1'],
136
+ ['+383 44 123 456', '+383'],
137
+ ['+55 11 91234 5678', '+55'],
138
+ ['+61 2 1234 5678', '+61'],
139
+ ['+81 3 1234 5678', '+81'],
140
+ ['+90 212 123 4567', '+90'],
141
+ ['+970 59 123 4567', '+970'],
142
+ ['+44 20 7946 0000', '+44'],
143
+ ]
144
+
145
+ it.each(cases)('resolves %s to dial code %s (longest prefix, sovereign first)', (value, expected) => {
146
+ render(<PhoneFieldHarness value={value} />)
147
+ expect(screen.getByText(expected)).toBeInTheDocument()
148
+ })
79
149
  })
@@ -95,6 +95,20 @@ describe('apiFetch', () => {
95
95
  )
96
96
  })
97
97
 
98
+ it('attaches requiredFeatures to ForbiddenError so callers can name the missing permission', async () => {
99
+ ;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
100
+ createMockResponse(403, {
101
+ error: 'Forbidden',
102
+ requiredFeatures: ['wms.manage_locations'],
103
+ }),
104
+ )
105
+
106
+ const rejection = await apiFetch('/api/wms/locations').catch((error: unknown) => error)
107
+ expect(rejection).toBeInstanceOf(ForbiddenError)
108
+ expect((rejection as ForbiddenError).requiredFeatures).toEqual(['wms.manage_locations'])
109
+ expect((rejection as ForbiddenError).status).toBe(403)
110
+ })
111
+
98
112
  it('throws ForbiddenError when ACL hints are missing', async () => {
99
113
  const response = createMockResponse(403, {
100
114
  error: 'Forbidden',
@@ -74,9 +74,17 @@ export function redirectToSessionRefresh() {
74
74
 
75
75
  export class ForbiddenError extends Error {
76
76
  readonly status = 403
77
- constructor(message = 'Forbidden') {
77
+ readonly requiredFeatures: string[] | null
78
+ readonly requiredRoles: string[] | null
79
+
80
+ constructor(
81
+ message = 'Forbidden',
82
+ options?: { requiredFeatures?: string[] | null; requiredRoles?: string[] | null },
83
+ ) {
78
84
  super(message)
79
85
  this.name = 'ForbiddenError'
86
+ this.requiredFeatures = options?.requiredFeatures?.length ? [...options.requiredFeatures] : null
87
+ this.requiredRoles = options?.requiredRoles?.length ? [...options.requiredRoles] : null
80
88
  }
81
89
  }
82
90
 
@@ -220,7 +228,9 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
220
228
  } else {
221
229
  msg = await res.clone().text().catch(() => 'Forbidden')
222
230
  }
223
- throw new ForbiddenError(msg)
231
+ // Attach ACL hints so callers (e.g. flashMutationError) can name the
232
+ // missing permission instead of surfacing a bare "Forbidden" toast.
233
+ throw new ForbiddenError(msg, { requiredFeatures: features, requiredRoles: roles })
224
234
  }
225
235
  // If already on login, just return the response for the caller to handle
226
236
  }