@incodetech/prizma-core-screens 0.1.2 → 0.2.3

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 (84) hide show
  1. package/dist/DemoScreensConfigScreen.d.ts +36 -0
  2. package/dist/DemoScreensConfigScreen.js +30 -0
  3. package/dist/DemoScreensConfigSheetScreen.d.ts +39 -0
  4. package/dist/DemoScreensConfigSheetScreen.js +33 -0
  5. package/dist/DemoScreensEditSheetScreen.d.ts +30 -0
  6. package/dist/DemoScreensEditSheetScreen.js +28 -0
  7. package/dist/DemoScreensEndScreen.d.ts +9 -21
  8. package/dist/DemoScreensEndScreen.js +9 -14
  9. package/dist/DemoScreensLaunchScreen.d.ts +28 -0
  10. package/dist/DemoScreensLaunchScreen.js +26 -0
  11. package/dist/DemoScreensLayout.css +542 -0
  12. package/dist/DemoScreensRedirectScreen.d.ts +50 -0
  13. package/dist/DemoScreensRedirectScreen.js +38 -0
  14. package/dist/DemoScreensResultScreen.d.ts +52 -0
  15. package/dist/DemoScreensResultScreen.js +46 -0
  16. package/dist/DemoScreensRunningScreen.d.ts +22 -0
  17. package/dist/DemoScreensRunningScreen.js +16 -0
  18. package/dist/DemoScreensSettingsScreen.d.ts +37 -0
  19. package/dist/DemoScreensSettingsScreen.js +40 -0
  20. package/dist/DemoScreensSplashScreen.d.ts +27 -0
  21. package/dist/DemoScreensSplashScreen.js +25 -0
  22. package/dist/DemoScreensStartScreen.d.ts +38 -0
  23. package/dist/DemoScreensStartScreen.js +37 -0
  24. package/dist/DemoScreensStrings.d.ts +192 -0
  25. package/dist/DemoScreensStrings.js +11 -11
  26. package/dist/DemoScreensTermsScreen.d.ts +28 -0
  27. package/dist/DemoScreensTermsScreen.js +26 -0
  28. package/dist/EkybAddressDetailsScreen.js +9 -5
  29. package/dist/EkybBusinessDetailsScreen.js +6 -3
  30. package/dist/EkybOwnersScreen.d.ts +4 -0
  31. package/dist/EkybOwnersScreen.js +13 -5
  32. package/dist/EkybStrings.d.ts +12 -1
  33. package/dist/EkycCountryCatalog.d.ts +46 -0
  34. package/dist/EkycCountryCatalog.js +139 -0
  35. package/dist/EkycCountryFormScreen.d.ts +36 -0
  36. package/dist/EkycCountryFormScreen.js +94 -0
  37. package/dist/EkycDetailsScreen.js +11 -4
  38. package/dist/EkycLayout.css +23 -0
  39. package/dist/EkycLicenseScreen.js +7 -3
  40. package/dist/EkycStrings.d.ts +26 -0
  41. package/dist/EmailInputStrings.d.ts +11 -1
  42. package/dist/FaceMatchComparisonScreen.d.ts +4 -2
  43. package/dist/FaceMatchComparisonScreen.js +3 -1
  44. package/dist/FaceMatchLayout.css +53 -3
  45. package/dist/FormScreen.css +74 -0
  46. package/dist/FormScreen.d.ts +17 -0
  47. package/dist/FormScreen.js +12 -1
  48. package/dist/PhoneInputStrings.d.ts +10 -1
  49. package/dist/index.d.ts +15 -2
  50. package/dist/index.js +13 -0
  51. package/dist/styles.css +1 -0
  52. package/package.json +62 -1
  53. package/src/DemoScreensConfigScreen.tsx +101 -0
  54. package/src/DemoScreensConfigSheetScreen.tsx +120 -0
  55. package/src/DemoScreensEditSheetScreen.tsx +103 -0
  56. package/src/DemoScreensEndScreen.tsx +14 -38
  57. package/src/DemoScreensLaunchScreen.tsx +96 -0
  58. package/src/DemoScreensLayout.css +542 -0
  59. package/src/DemoScreensRedirectScreen.tsx +190 -0
  60. package/src/DemoScreensResultScreen.tsx +115 -0
  61. package/src/DemoScreensRunningScreen.tsx +45 -0
  62. package/src/DemoScreensSettingsScreen.tsx +101 -0
  63. package/src/DemoScreensSplashScreen.tsx +47 -0
  64. package/src/DemoScreensStartScreen.tsx +105 -0
  65. package/src/DemoScreensStrings.ts +204 -11
  66. package/src/DemoScreensTermsScreen.tsx +82 -0
  67. package/src/EkybAddressDetailsScreen.tsx +7 -1
  68. package/src/EkybBusinessDetailsScreen.tsx +6 -1
  69. package/src/EkybOwnersScreen.tsx +46 -2
  70. package/src/EkybStrings.ts +12 -0
  71. package/src/EkycCountryCatalog.ts +199 -0
  72. package/src/EkycCountryFormScreen.tsx +184 -0
  73. package/src/EkycDetailsScreen.tsx +29 -20
  74. package/src/EkycLayout.css +23 -0
  75. package/src/EkycLicenseScreen.tsx +22 -16
  76. package/src/EkycStrings.ts +25 -0
  77. package/src/EmailInputStrings.ts +11 -1
  78. package/src/FaceMatchComparisonScreen.tsx +11 -3
  79. package/src/FaceMatchLayout.css +53 -3
  80. package/src/FormScreen.css +74 -0
  81. package/src/FormScreen.tsx +27 -1
  82. package/src/PhoneInputStrings.ts +10 -1
  83. package/src/index.ts +65 -1
  84. package/src/styles.css +1 -0
@@ -0,0 +1,199 @@
1
+ // eKYC — the field catalogue by country and data provider.
2
+ //
3
+ // Figma: `👁️ eKYC Initial screen options` (`TWgYZVhurQCizfC4tfLEut`), section
4
+ // `2098:20867` "1-1-1-eKYC-empty (all options)", read on 2026-09-24. Fifteen
5
+ // step-1 frames, one per country and provider, eight of them with a step 2 and
6
+ // US Telco 1 with a step 3. Every frame is archetype E on the same skeleton;
7
+ // what changes is the LIST OF FIELDS per step. So a country is data, not a
8
+ // screen: `EkycCountryFormScreen` renders any entry of this catalogue.
9
+ //
10
+ // Labels are copy and live in `EkycStrings.country.fields`, keyed by
11
+ // `EkycFieldKey`, so a locale pack translates them once. The catalogue only
12
+ // says WHICH fields, in WHICH order, with WHICH kind of Core input.
13
+ //
14
+ // Node ids per variant are the frame ids in that section; a step's id is the
15
+ // frame that draws it. Nothing here is invented: a field appears because the
16
+ // frame draws it, with the label the frame draws (DEVIATIONS #EKC-1). Two
17
+ // things the frames draw unevenly were ALIGNED by the owner (Elena,
18
+ // 2026-09-24): every step uses the module's "Enter your details" title and
19
+ // subtitle (the US licence frame's copy) rather than a bare "eKYC
20
+ // Verification", and no variant draws a close in the nav; steps after the
21
+ // first get the archetype's Back button instead of a back arrow (#EKC-2).
22
+
23
+ export type EkycFieldKind = 'text' | 'date' | 'phone' | 'dropdown' | 'radio';
24
+
25
+ export type EkycFieldKey =
26
+ | 'firstName' | 'middleName' | 'middleNameOptional' | 'lastName' | 'surname' | 'maternalSurname'
27
+ | 'dateOfBirth' | 'email' | 'phone' | 'ssnLast4' | 'ssnOrItin' | 'nationality' | 'cpf'
28
+ | 'nationalId' | 'taxId' | 'gender'
29
+ | 'licenseNumber' | 'licenseState' | 'licenseExpiration'
30
+ | 'street' | 'houseNumber' | 'city' | 'state' | 'stateCode' | 'zipCode' | 'postalCode';
31
+
32
+ export interface EkycField {
33
+ key: EkycFieldKey;
34
+ kind: EkycFieldKind;
35
+ /** Draws the `*` after the label, as the US driver's licence frame does. */
36
+ required?: boolean;
37
+ /** Placeholder key into `EkycStrings.country.placeholders`, when the frame draws one. */
38
+ placeholder?: string;
39
+ /** For `phone`: the ISO code of the dial code the frame shows (+1, +52, +34…). */
40
+ dialCountry?: string;
41
+ /**
42
+ * Desktop and tablet pairing (DEVIATIONS #EKC-5). Two ADJACENT fields with
43
+ * the same `row` share a `PrizmaFormRow`: two lanes of 202 on a wide frame,
44
+ * the ordinary stack on the phone (the archetype's container query, 420 and
45
+ * up, decides; nothing here re-declares a width). A field without a `row`
46
+ * spans the column. The rule mirrors eKYB's frames: a name pair shares
47
+ * (first | last, first | surname, surname | maternal surname), nationality |
48
+ * CPF share, State | Postal or Zip code share; email, date of birth, the
49
+ * phone (a Combined field never shares a row, #EKC-7), every identifier
50
+ * (SSN, ITIN, national ID, tax ID, CPF alone), the gender pair and street,
51
+ * house number, city span.
52
+ */
53
+ row?: number;
54
+ }
55
+
56
+ export interface EkycCountryStep {
57
+ /** Figma frame id. */
58
+ node: string;
59
+ /** A section heading under the title ("Address details", "Driver's License details"), when drawn. */
60
+ section?: 'address' | 'license';
61
+ fields: EkycField[];
62
+ }
63
+
64
+ export interface EkycCountryVariant {
65
+ /** Stable id; screen ids are `ekyc-<id>-step<n>`. */
66
+ id: string;
67
+ /** ISO 3166-1 alpha-2 of the country; `XX` for a country-agnostic provider (IPQS). */
68
+ country: string;
69
+ /** Country name and provider, as the frame names them. */
70
+ label: string;
71
+ provider: string;
72
+ /** Segments the frame's stepper draws; 0 when it draws none. */
73
+ stepper: number;
74
+ steps: EkycCountryStep[];
75
+ }
76
+
77
+ const t = (key: EkycFieldKey, extra: Partial<EkycField> = {}): EkycField => ({ key, kind: 'text', ...extra });
78
+ const date = (key: EkycFieldKey = 'dateOfBirth', extra: Partial<EkycField> = {}): EkycField => ({ key, kind: 'date', ...extra });
79
+ // Required fields carry Core's asterisk (`required` + `requiredHint`), the
80
+ // owner's rule (Elena, 2026-09-24, DEVIATIONS #EKC-6): first name, last name or
81
+ // surname, email and phone on every variant. Middle name (plain or "Optional")
82
+ // and maternal surname stay optional; date of birth, identifiers, address and
83
+ // licence fields keep what their frame draws (only the US licence step 1 draws
84
+ // asterisks on them). `showErrors` flags required empty fields only.
85
+ const phone = (dialCountry: string, extra: Partial<EkycField> = {}): EkycField => ({ key: 'phone', kind: 'phone', dialCountry, required: true, ...extra });
86
+ const email = (extra: Partial<EkycField> = {}): EkycField => t('email', { required: true, ...extra });
87
+
88
+ // The wide-frame pairs (`row`, DEVIATIONS #EKC-5). A row number is only a
89
+ // grouping id, local to its step; 1 is the name pair, 2 Brazil's nationality |
90
+ // CPF, 3 the State | Postal pair of an address. The phone field NEVER shares a
91
+ // row: Core's Combined (dial code + number) collapses to a three-digit stub in
92
+ // a 202 lane (Elena, 2026-09-24, #EKC-7), and date of birth spans with it.
93
+ const NAME_PAIR = (last: EkycFieldKey = 'lastName'): EkycField[] => [t('firstName', { required: true, row: 1 }), t(last, { required: true, row: 1 })];
94
+ const DOB_PHONE = (dialCountry: string): EkycField[] => [date(), phone(dialCountry)];
95
+
96
+ const US_ADDRESS: EkycField[] = [t('street'), t('houseNumber'), t('city'), { key: 'state', kind: 'dropdown', placeholder: 'selectState', row: 3 }, t('zipCode', { row: 3 })];
97
+ const CODE_ADDRESS = (placeholder?: string): EkycField[] => [t('street'), t('houseNumber'), t('city'), t('stateCode', { row: 3, ...(placeholder ? { placeholder } : {}) }), t('postalCode', { row: 3 })];
98
+ const LICENSE: EkycField[] = [t('licenseNumber'), { key: 'licenseState', kind: 'dropdown', placeholder: 'selectState', row: 1 }, date('licenseExpiration', { row: 1 })];
99
+
100
+ export const EKYC_COUNTRY_CATALOG: EkycCountryVariant[] = [
101
+ {
102
+ id: 'us-drivers-license', country: 'US', label: 'United States', provider: "Driver's license", stepper: 2,
103
+ steps: [
104
+ /* First | Last share the wide row, so Middle name follows the pair here
105
+ and in `EkycDetailsScreen` (the same frame). On the phone the stack
106
+ reads First, Last, Middle, Date of birth — the one order change the
107
+ desktop pairing costs (#EKC-5). */
108
+ { node: '2101:25871', fields: [t('firstName', { required: true, row: 1 }), t('lastName', { required: true, row: 1 }), t('middleName'), date('dateOfBirth', { required: true })] },
109
+ { node: '5849:73280', section: 'license', fields: LICENSE },
110
+ ],
111
+ },
112
+ {
113
+ id: 'us-telco-1', country: 'US', label: 'United States', provider: 'Telco 1', stepper: 3,
114
+ steps: [
115
+ { node: '2098:9575', fields: [...NAME_PAIR(), email(), ...DOB_PHONE('US'), t('ssnLast4')] },
116
+ { node: '5854:76222', section: 'license', fields: [t('licenseNumber'), { key: 'licenseState', kind: 'dropdown', placeholder: 'selectState' }] },
117
+ { node: '5854:76221', section: 'address', fields: US_ADDRESS },
118
+ ],
119
+ },
120
+ {
121
+ id: 'us-telco-2', country: 'US', label: 'United States', provider: 'Telco 2', stepper: 2,
122
+ steps: [
123
+ { node: '2098:9578', fields: [...NAME_PAIR(), ...DOB_PHONE('US')] },
124
+ { node: '5857:33074', section: 'address', fields: US_ADDRESS },
125
+ ],
126
+ },
127
+ {
128
+ id: 'us-credit-bureau', country: 'US', label: 'United States', provider: 'Credit bureau', stepper: 2,
129
+ steps: [
130
+ /* Same First | Last pair as the licence variant; Middle name (Optional)
131
+ follows it and spans (#EKC-5). */
132
+ { node: '2098:9577', fields: [...NAME_PAIR(), t('middleNameOptional'), ...DOB_PHONE('US'), email(), t('ssnOrItin')] },
133
+ { node: '5857:34094', section: 'address', fields: US_ADDRESS },
134
+ ],
135
+ },
136
+ {
137
+ id: 'mx-consumer', country: 'MX', label: 'Mexico', provider: 'Consumer', stepper: 0,
138
+ /* Mexico pairs the two surnames, so First name spans on its own. */
139
+ steps: [{ node: '2098:9572', fields: [t('firstName', { required: true }), t('surname', { required: true, row: 1 }), t('maternalSurname', { row: 1 }), phone('MX'), email()] }],
140
+ },
141
+ {
142
+ id: 'br-govt', country: 'BR', label: 'Brazil', provider: 'Government', stepper: 2,
143
+ steps: [
144
+ { node: '2098:9574', fields: [...NAME_PAIR('surname'), email(), date(), { key: 'nationality', kind: 'dropdown', row: 2 }, t('cpf', { row: 2 })] },
145
+ { node: '5857:34935', section: 'address', fields: CODE_ADDRESS('stateCodeBr') },
146
+ ],
147
+ },
148
+ {
149
+ id: 'ca-res-credit', country: 'CA', label: 'Canada', provider: 'Residential credit', stepper: 2,
150
+ steps: [
151
+ { node: '2098:9570', fields: [...NAME_PAIR(), email(), ...DOB_PHONE('CA'), t('ssnOrItin')] },
152
+ { node: '5857:36450', section: 'address', fields: CODE_ADDRESS('stateCodeCa') },
153
+ ],
154
+ },
155
+ {
156
+ id: 'uk-credit-bureau', country: 'GB', label: 'United Kingdom', provider: 'Credit bureau', stepper: 2,
157
+ steps: [
158
+ { node: '2098:9569', fields: [...NAME_PAIR('surname'), date()] },
159
+ { node: '5857:36888', section: 'address', fields: CODE_ADDRESS() },
160
+ ],
161
+ },
162
+ {
163
+ id: 'es-telephone-register', country: 'ES', label: 'Spain', provider: 'Telephone register', stepper: 2,
164
+ steps: [
165
+ { node: '2098:9573', fields: [...NAME_PAIR('surname'), ...DOB_PHONE('ES'), t('nationalId')] },
166
+ { node: '5857:37341', section: 'address', fields: CODE_ADDRESS() },
167
+ ],
168
+ },
169
+ {
170
+ id: 'cl-official-census', country: 'CL', label: 'Chile', provider: 'Official census', stepper: 0,
171
+ steps: [{ node: '2098:9571', fields: [...NAME_PAIR('surname'), date(), t('nationalId')] }],
172
+ },
173
+ {
174
+ id: 'ar-civil-register', country: 'AR', label: 'Argentina', provider: 'Official civil register', stepper: 2,
175
+ steps: [
176
+ { node: '2098:9568', fields: [...NAME_PAIR('surname'), { key: 'gender', kind: 'radio' }, email(), date(), t('nationalId'), t('taxId')] },
177
+ { node: '5857:38015', section: 'address', fields: [t('street'), t('houseNumber'), t('city'), { key: 'state', kind: 'dropdown', placeholder: 'selectState', row: 3 }, t('postalCode', { row: 3 })] },
178
+ ],
179
+ },
180
+ {
181
+ id: 'gt', country: 'GT', label: 'Guatemala', provider: 'Registry', stepper: 0,
182
+ steps: [{ node: '2098:9566', fields: [...NAME_PAIR('surname'), date(), t('nationalId')] }],
183
+ },
184
+ {
185
+ id: 'co', country: 'CO', label: 'Colombia', provider: 'Registry', stepper: 0,
186
+ steps: [{ node: '2098:9567', fields: [...NAME_PAIR('surname'), t('nationalId')] }],
187
+ },
188
+ {
189
+ id: 'cr', country: 'CR', label: 'Costa Rica', provider: 'Registry', stepper: 0,
190
+ steps: [{ node: '2098:9565', fields: [...NAME_PAIR('surname'), t('nationalId')] }],
191
+ },
192
+ {
193
+ id: 'ipqs', country: 'XX', label: 'Any country', provider: 'IPQS', stepper: 0,
194
+ steps: [{ node: '2098:9564', fields: [email(), phone('US')] }],
195
+ },
196
+ ];
197
+
198
+ export const ekycCountryVariant = (id: string): EkycCountryVariant | undefined =>
199
+ EKYC_COUNTRY_CATALOG.find(v => v.id === id);
@@ -0,0 +1,184 @@
1
+ import React, { forwardRef } from 'react';
2
+ import { PrizmaTextField } from '@incodetech/prizma-core-web/text-field';
3
+ import { PrizmaDateField } from '@incodetech/prizma-core-web/date-field';
4
+ import { PrizmaDropdown } from '@incodetech/prizma-core-web/dropdown';
5
+ import { PrizmaCombined, type PrizmaCountry } from '@incodetech/prizma-core-web/combined';
6
+ import { PrizmaRadioGroup } from '@incodetech/prizma-core-web/radio-group';
7
+ import { PrizmaRadio } from '@incodetech/prizma-core-web/radio';
8
+ import { PrizmaStepper, type PrizmaStepperState } from '@incodetech/prizma-core-web/stepper';
9
+ import { PrizmaButton } from '@incodetech/prizma-core-web/button';
10
+ import { PrizmaFormScreen, PrizmaFormRow, type PrizmaFormScreenProps } from './FormScreen';
11
+ import type { PrizmaScreenActionHandler, PrizmaScreenOverrides } from './types';
12
+ import type { EkycCountryValues, EkycStrings } from './EkycStrings';
13
+ import { ekycCountryVariant, type EkycCountryVariant, type EkycField } from './EkycCountryCatalog';
14
+ import './ScreenLayout.css';
15
+ import './EkycLayout.css';
16
+
17
+ export interface EkycCountryFormScreenProps
18
+ extends Omit<
19
+ PrizmaFormScreenProps,
20
+ 'title' | 'subtitle' | 'ctaLabel' | 'ctaDisabled' | 'verifiedLabel' | 'children' | 'navLabel' | 'progress' | 'scrollFields' | 'linkContent'
21
+ > {
22
+ strings: EkycStrings;
23
+ /** A catalogue id (`us-telco-1`) or the variant itself. */
24
+ variant: string | EkycCountryVariant;
25
+ /** 1-based step of the variant. Default 1. */
26
+ step?: number;
27
+ /** Starting values by field key. Empty renders the resting empty field. */
28
+ values?: EkycCountryValues;
29
+ /**
30
+ * The state after Continue was pressed with fields still empty: every empty
31
+ * field shows Core's error state and `country.requiredError` under it.
32
+ * Continue itself is ALWAYS enabled (Elena, 2026-09-24): validation happens
33
+ * on press, not by greying the button.
34
+ */
35
+ showErrors?: boolean;
36
+ /** Dial codes the phone field offers. Runtime data, never copy. */
37
+ phoneCountries?: PrizmaCountry[];
38
+ /** Options for the dropdown fields, keyed by field key. Runtime data. */
39
+ options?: Record<string, { value: string; label: string }[]>;
40
+ screenId?: string;
41
+ overrides?: PrizmaScreenOverrides;
42
+ onAction?: PrizmaScreenActionHandler;
43
+ }
44
+
45
+ /**
46
+ * eKYC — one step of one by-country variant, built on the Form archetype.
47
+ *
48
+ * The section `2098:20867` draws fifteen variants on the same skeleton (nav →
49
+ * optional stepper → title → fields → Continue → tag) and differs only in the
50
+ * list of fields. This screen reads that list from `EkycCountryCatalog.ts` and
51
+ * composes Core's own inputs for it — text, date, phone (`PrizmaCombined`),
52
+ * dropdown, radio pair — so a new country is a catalogue entry, never a file.
53
+ *
54
+ * Deliberate, per frame and per owner:
55
+ * - Continue is ALWAYS enabled (Elena, 2026-09-24, overriding the grey button
56
+ * most frames draw): pressing it with empty fields is the `showErrors` state,
57
+ * Core's error ring and message under each empty field. The radio pair has
58
+ * no error state in Core, so an empty gender shows none (DEVIATIONS #EKC-4).
59
+ * - ONE title per step (Elena, 2026-09-24): the personal-data step is "Enter
60
+ * your details" with its subtitle; a licence step is titled "Driver's
61
+ * License details" and an address step "Address details", with nothing
62
+ * above them. The frames drew "eKYC Verification" over a section heading;
63
+ * the owner aligned the copy to one H2 per form.
64
+ * - No close in the nav (Core's nav has none, GUIDELINES §3, and the owner
65
+ * asked for none). A step after the first gets the archetype's Back button,
66
+ * as eKYB does, instead of the back arrow four frames drew. DEVIATIONS #EKC-2.
67
+ * - Desktop and tablet take the eKYB shape (`responsive`, exploration
68
+ * `ekyb-desktop`): two adjacent catalogue fields with the same `row` share a
69
+ * `PrizmaFormRow`, so on a wide frame they sit in two lanes and on the phone
70
+ * they stack exactly as before. Which fields pair is the catalogue's call,
71
+ * never this screen's (DEVIATIONS #EKC-5).
72
+ */
73
+
74
+ /** Adjacent fields sharing a `row` become one group; the rest stand alone. */
75
+ const groupRows = (fields: EkycField[]): EkycField[][] => {
76
+ const groups: EkycField[][] = [];
77
+ for (const f of fields) {
78
+ const last = groups[groups.length - 1];
79
+ if (f.row != null && last && last[0].row === f.row) last.push(f);
80
+ else groups.push([f]);
81
+ }
82
+ return groups;
83
+ };
84
+ export const EkycCountryFormScreen = forwardRef<HTMLDivElement, EkycCountryFormScreenProps>(
85
+ ({ strings, variant, step = 1, values, showErrors = false, phoneCountries, options, screenId, overrides, onAction, ...shell }, ref) => {
86
+ const v = typeof variant === 'string' ? ekycCountryVariant(variant) : variant;
87
+ if (!v) return null;
88
+ const s = v.steps[Math.min(Math.max(step, 1), v.steps.length) - 1];
89
+ const c = strings.country;
90
+ const stepperState: PrizmaStepperState = step <= 1 ? 'Start' : (Math.min(step, 6) as PrizmaStepperState);
91
+
92
+ const label = (f: EkycField) => c.fields[f.key] ?? f.key;
93
+ const placeholder = (f: EkycField) => (f.placeholder ? c.placeholders[f.placeholder] : c.placeholders[f.key]);
94
+
95
+ const renderField = (f: EkycField) => {
96
+ // Only a REQUIRED empty field is an error on press (Elena, 2026-09-24,
97
+ // DEVIATIONS #EKC-6): an optional field left blank shows nothing.
98
+ const missing = showErrors && Boolean(f.required) && !values?.[f.key];
99
+ const common = {
100
+ key: f.key, label: label(f), required: f.required, requiredHint: strings.details.requiredHint,
101
+ error: missing || undefined, errorText: missing ? c.requiredError : undefined,
102
+ } as const;
103
+ switch (f.kind) {
104
+ case 'date':
105
+ return <PrizmaDateField {...common} placeholder={placeholder(f)} defaultValue={values?.[f.key] ?? ''} />;
106
+ case 'phone': {
107
+ const country = phoneCountries?.find(p => p.code === f.dialCountry) ?? phoneCountries?.[0];
108
+ return (
109
+ <PrizmaCombined
110
+ {...common}
111
+ countries={phoneCountries}
112
+ defaultCountry={country}
113
+ countryListLabel={c.countryListLabel}
114
+ placeholder={placeholder(f)}
115
+ defaultValue={values?.[f.key] ?? ''}
116
+ autoComplete="off"
117
+ />
118
+ );
119
+ }
120
+ case 'dropdown':
121
+ return (
122
+ <PrizmaDropdown
123
+ {...common}
124
+ placeholder={placeholder(f)}
125
+ options={options?.[f.key] ?? []}
126
+ defaultValue={values?.[f.key] ?? null}
127
+ />
128
+ );
129
+ case 'radio':
130
+ return (
131
+ <PrizmaRadioGroup key={f.key} name={f.key} legend={label(f)} defaultValue={values?.[f.key]} className="ekyc-country__radios">
132
+ {c.genderOptions.map(o => (
133
+ <PrizmaRadio key={o.value} value={o.value} textSize="m" showText text={o.label} />
134
+ ))}
135
+ </PrizmaRadioGroup>
136
+ );
137
+ default:
138
+ return <PrizmaTextField {...common} placeholder={placeholder(f)} autoComplete="off" defaultValue={values?.[f.key] ?? ''} />;
139
+ }
140
+ };
141
+
142
+ return (
143
+ <PrizmaFormScreen
144
+ ref={ref}
145
+ scrollFields
146
+ progress={
147
+ v.stepper > 0 ? (
148
+ <PrizmaStepper steps={v.stepper as 2 | 3} currentState={stepperState} showIcons={false} aria-label={strings.stepperLabel} />
149
+ ) : undefined
150
+ }
151
+ title={s.section === 'address' ? c.addressSection : s.section === 'license' ? c.licenseSection : strings.details.title}
152
+ subtitle={s.section ? undefined : strings.details.subtitle}
153
+ linkContent={
154
+ step > 1 ? (
155
+ <PrizmaButton variant="secondary" size="L" onClick={() => onAction?.({ type: 'secondary', screenId })}>
156
+ {c.backLabel}
157
+ </PrizmaButton>
158
+ ) : undefined
159
+ }
160
+ ctaLabel={c.ctaLabel}
161
+ verifiedLabel={strings.verifiedLabel}
162
+ navLabel={strings.navLabel}
163
+ screenId={screenId}
164
+ overrides={{ align: overrides?.align ?? 'left', logo: overrides?.logo }}
165
+ onAction={onAction}
166
+ /* The eKYB desktop shape: catalogue pairs become two lanes at 420 and
167
+ up, Back | Continue share a row. The phone stack is untouched. */
168
+ responsive
169
+ {...shell}
170
+ >
171
+ {groupRows(s.fields).map(group =>
172
+ group.length > 1 ? (
173
+ <PrizmaFormRow key={group.map(f => f.key).join('+')}>{group.map(renderField)}</PrizmaFormRow>
174
+ ) : (
175
+ renderField(group[0])
176
+ ),
177
+ )}
178
+ </PrizmaFormScreen>
179
+ );
180
+ },
181
+ );
182
+ EkycCountryFormScreen.displayName = 'EkycCountryFormScreen';
183
+
184
+ export default EkycCountryFormScreen;
@@ -2,7 +2,7 @@ import React, { forwardRef } from 'react';
2
2
  import { PrizmaTextField } from '@incodetech/prizma-core-web/text-field';
3
3
  import { PrizmaDateField } from '@incodetech/prizma-core-web/date-field';
4
4
  import { PrizmaStepper } from '@incodetech/prizma-core-web/stepper';
5
- import { PrizmaFormScreen, type PrizmaFormScreenProps } from './FormScreen';
5
+ import { PrizmaFormScreen, PrizmaFormRow, type PrizmaFormScreenProps } from './FormScreen';
6
6
  import type { PrizmaScreenActionHandler, PrizmaScreenOverrides } from './types';
7
7
  import type { EkycDetailsState, EkycDetailsValues, EkycStrings } from './EkycStrings';
8
8
  import './ScreenLayout.css';
@@ -131,33 +131,42 @@ export const EkycDetailsScreen = forwardRef<HTMLDivElement, EkycDetailsScreenPro
131
131
  that passes its own `align` still wins. */
132
132
  overrides={{ align: overrides?.align ?? 'left', logo: overrides?.logo }}
133
133
  onAction={onAction}
134
+ /* Exploration ekyb-desktop, applied to eKYC: on a tablet or desktop
135
+ frame First name | Last name share a row; Middle name and Date of
136
+ birth span the column. So that the pair is adjacent, Middle name
137
+ now FOLLOWS Last name, on the phone too (First, Last, Middle, Date of
138
+ birth), the one order change the pairing costs: the frames drew
139
+ First, Middle, Last. DEVIATIONS #EKC-5. */
140
+ responsive
134
141
  {...shell}
135
142
  >
136
- <PrizmaTextField
137
- className={focused ? 'prizma-input-textfield--focused' : undefined}
138
- label={strings.details.firstNameLabel}
139
- required
140
- requiredHint={strings.details.requiredHint}
141
- defaultValue={values?.firstName ?? ''}
142
- autoComplete="off"
143
- /* Only 2101:8159 draws the ✕, and only in this field. */
144
- clearable={focused}
145
- clearLabel={strings.details.clearLabel}
146
- />
143
+ <PrizmaFormRow>
144
+ <PrizmaTextField
145
+ className={focused ? 'prizma-input-textfield--focused' : undefined}
146
+ label={strings.details.firstNameLabel}
147
+ required
148
+ requiredHint={strings.details.requiredHint}
149
+ defaultValue={values?.firstName ?? ''}
150
+ autoComplete="off"
151
+ /* Only 2101:8159 draws the ✕, and only in this field. */
152
+ clearable={focused}
153
+ clearLabel={strings.details.clearLabel}
154
+ />
155
+ <PrizmaTextField
156
+ label={strings.details.lastNameLabel}
157
+ required
158
+ requiredHint={strings.details.requiredHint}
159
+ defaultValue={values?.lastName ?? ''}
160
+ autoComplete="off"
161
+ clearable={false}
162
+ />
163
+ </PrizmaFormRow>
147
164
  <PrizmaTextField
148
165
  label={strings.details.middleNameLabel}
149
166
  defaultValue={values?.middleName ?? ''}
150
167
  autoComplete="off"
151
168
  clearable={false}
152
169
  />
153
- <PrizmaTextField
154
- label={strings.details.lastNameLabel}
155
- required
156
- requiredHint={strings.details.requiredHint}
157
- defaultValue={values?.lastName ?? ''}
158
- autoComplete="off"
159
- clearable={false}
160
- />
161
170
  <PrizmaDateField
162
171
  label={strings.details.dateOfBirthLabel}
163
172
  required
@@ -37,3 +37,26 @@
37
37
  .prizma-input-textfield__clear--show {
38
38
  display: inline-flex;
39
39
  }
40
+
41
+ /* ── The gender pair sits on one row ──────────────────────────────────────────
42
+ * Argentina's frame (2098:9568) draws "Female Male" side by side under the
43
+ * "Gender" label. Core's `PrizmaRadioGroup` stacks its radios in a column and
44
+ * has no row option, so this releases that one direction for the pair: the
45
+ * legend keeps the whole first line, the two radios share the next one at
46
+ * Spacing/24. Core's radio, its label, its size and colours are untouched.
47
+ * CORE REQUEST: a `direction="row"` on `PrizmaRadioGroup` would retire this
48
+ * rule (DEVIATIONS #EKC-3). */
49
+ .ekyc-country__radios {
50
+ flex-direction: row;
51
+ flex-wrap: wrap;
52
+ column-gap: var(--prizma-spacing-24);
53
+ }
54
+ .ekyc-country__radios .prizma-radio-group__legend {
55
+ flex-basis: 100%;
56
+ }
57
+ /* Core's radio is FILL by design (radio.md §3); on the row each one hugs its
58
+ * label so the pair reads "Female Male" as drawn. Same release, same request. */
59
+ .ekyc-country__radios .prizma-radio {
60
+ width: auto;
61
+ flex: 0 0 auto;
62
+ }
@@ -3,7 +3,7 @@ import { PrizmaTextField } from '@incodetech/prizma-core-web/text-field';
3
3
  import { PrizmaDateField } from '@incodetech/prizma-core-web/date-field';
4
4
  import { PrizmaDropdown } from '@incodetech/prizma-core-web/dropdown';
5
5
  import { PrizmaStepper, type PrizmaStepperState } from '@incodetech/prizma-core-web/stepper';
6
- import { PrizmaFormScreen, type PrizmaFormScreenProps } from './FormScreen';
6
+ import { PrizmaFormScreen, PrizmaFormRow, type PrizmaFormScreenProps } from './FormScreen';
7
7
  import type { PrizmaScreenActionHandler, PrizmaScreenOverrides } from './types';
8
8
  import type {
9
9
  EkycLicenseState,
@@ -129,6 +129,10 @@ export const EkycLicenseScreen = forwardRef<HTMLDivElement, EkycLicenseScreenPro
129
129
  /* Left-aligned title block, same as page 1; a host `align` still wins. */
130
130
  overrides={{ align: overrides?.align ?? 'left', logo: overrides?.logo }}
131
131
  onAction={onAction}
132
+ /* Exploration ekyb-desktop, applied to eKYC: on a wide frame the
133
+ licence number spans and State | Expiration date share a row; the
134
+ phone stack is untouched. DEVIATIONS #EKC-5. */
135
+ responsive
132
136
  {...shell}
133
137
  >
134
138
  <PrizmaTextField
@@ -140,21 +144,23 @@ export const EkycLicenseScreen = forwardRef<HTMLDivElement, EkycLicenseScreenPro
140
144
  /* No section on this page draws the clear ✕. */
141
145
  clearable={false}
142
146
  />
143
- <PrizmaDropdown
144
- label={strings.license.stateLabel}
145
- options={stateOptions}
146
- defaultValue={values?.state ?? null}
147
- disabled={busy}
148
- />
149
- <PrizmaDateField
150
- label={strings.license.expirationLabel}
151
- defaultValue={values?.expirationDate ?? ''}
152
- autoComplete="off"
153
- disabled={busy}
154
- /* No `onCalendarClick` — see the note in `EkycDetailsScreen`: Core
155
- renders the calendar as a decorative aria-hidden glyph without one,
156
- so `calendarLabel` would do nothing and is not passed. */
157
- />
147
+ <PrizmaFormRow>
148
+ <PrizmaDropdown
149
+ label={strings.license.stateLabel}
150
+ options={stateOptions}
151
+ defaultValue={values?.state ?? null}
152
+ disabled={busy}
153
+ />
154
+ <PrizmaDateField
155
+ label={strings.license.expirationLabel}
156
+ defaultValue={values?.expirationDate ?? ''}
157
+ autoComplete="off"
158
+ disabled={busy}
159
+ /* No `onCalendarClick` — see the note in `EkycDetailsScreen`: Core
160
+ renders the calendar as a decorative aria-hidden glyph without
161
+ one, so `calendarLabel` would do nothing and is not passed. */
162
+ />
163
+ </PrizmaFormRow>
158
164
  </PrizmaFormScreen>
159
165
  );
160
166
  },
@@ -57,6 +57,9 @@ export type EkycDetailsState = 'default' | 'focused' | 'filled';
57
57
  */
58
58
  export type EkycLicenseState = 'default' | 'filled' | 'loading';
59
59
 
60
+ /** Values for a by-country step, keyed by `EkycFieldKey`. Empty renders the resting empty field. */
61
+ export type EkycCountryValues = Partial<Record<string, string>>;
62
+
60
63
  /** One row of the licence-state chooser. Runtime data, never copy — the host
61
64
  * supplies the jurisdictions its tenant accepts, exactly as Phone Input's
62
65
  * `PhoneInputCountry` supplies dial codes. */
@@ -139,6 +142,28 @@ export interface EkycStrings {
139
142
  ctaLoadingLabel: string;
140
143
  };
141
144
 
145
+ /**
146
+ * The by-country variants (`EkycCountryCatalog.ts`, Figma section
147
+ * 2098:20867). Field labels keyed by `EkycFieldKey`, placeholders by the
148
+ * catalogue's `placeholder` keys; one map, translated once.
149
+ */
150
+ country: {
151
+ /** Section headings drawn under the title on a step 2 / 3. */
152
+ addressSection: string;
153
+ licenseSection: string;
154
+ /** Accessible name of the phone field's dial-code list. */
155
+ countryListLabel: string;
156
+ /** The gender radio pair, Argentina. */
157
+ genderOptions: { value: string; label: string }[];
158
+ fields: Record<string, string>;
159
+ placeholders: Record<string, string>;
160
+ ctaLabel: string;
161
+ /** The secondary button on a step after the first. */
162
+ backLabel: string;
163
+ /** Under every empty field once Continue was pressed (`showErrors`). */
164
+ requiredError: string;
165
+ };
166
+
142
167
  /** `eKYC.processing` — spinner over the status line, no button. */
143
168
  processing: {
144
169
  title: string;
@@ -63,7 +63,17 @@ export interface EmailInputStrings {
63
63
  verify: {
64
64
  /** H2 ("Verify your email"). */
65
65
  title: string;
66
- /** H5 ("We'll send you a code to ensure it's yours"). */
66
+ /**
67
+ * H5 ("We'll use it to verify it's yours").
68
+ *
69
+ * DELIBERATELY SILENT ABOUT THE CODE, for the same reason as Phone Input's
70
+ * twin line. This module ships in TWO configurations and the `Without a
71
+ * one-time code` one never asks for a code at all (see the `noOtp` variant
72
+ * in `flow/graphs/email-input.ts`: the spinner runs straight into the
73
+ * verdict). One screen serves both, and which configuration the session
74
+ * wants is not something this line can know. It answers "why do you want my
75
+ * address?" instead, which holds in both.
76
+ */
67
77
  subtitle: string;
68
78
  /** Field placeholder ("Email"). */
69
79
  placeholder: string;
@@ -16,8 +16,10 @@ import './FaceMatchLayout.css';
16
16
  * contract as `camera` on the capture screens) — Face Match never captures. */
17
17
  export type FaceMatchPhotoRole = 'selfie' | 'id' | 'nfc' | 'secondId';
18
18
 
19
- /** Host-supplied portraits, one per role the chosen variant asks for. Any role
20
- * left out renders an empty neutral disc. */
19
+ /** Host-supplied portraits, one per role the chosen variant asks for. A role
20
+ * left out renders the empty state: a Brand/50 disc, marked with
21
+ * `.face-match-cluster__photo--empty`. Interim treatment (2026-09-25) while
22
+ * the designed no-photo state is drawn; see FaceMatchLayout.css. */
21
23
  export type FaceMatchComparisonPhotos = Partial<Record<FaceMatchPhotoRole, ReactNode>>;
22
24
 
23
25
  /**
@@ -179,7 +181,13 @@ export const FaceMatchComparisonScreen = forwardRef<
179
181
  {label}
180
182
  </PrizmaTooltip>
181
183
  ) : null}
182
- <div className="face-match-cluster__photo">
184
+ <div
185
+ className={
186
+ photos?.[role]
187
+ ? 'face-match-cluster__photo'
188
+ : 'face-match-cluster__photo face-match-cluster__photo--empty'
189
+ }
190
+ >
183
191
  {photos?.[role]}
184
192
  </div>
185
193
  </div>