@block-auth.io/angular 0.2.10 → 0.3.0

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.
@@ -1,14 +1,31 @@
1
1
  import {
2
2
  ChangeDetectionStrategy,
3
3
  Component,
4
+ DestroyRef,
5
+ ElementRef,
6
+ afterNextRender,
4
7
  computed,
5
8
  effect,
9
+ inject,
6
10
  input,
7
11
  output,
8
12
  signal,
13
+ viewChild,
9
14
  } from '@angular/core';
10
- import { $locale, t as translate, type TextKey } from '@block-auth.io/core';
15
+ import { $locale, t as translate, type TextKey, type TxnDataParam } from '@block-auth.io/core';
11
16
  import { injectStore } from '../../../inject-store';
17
+ import { PhoneInputComponent } from './phone-input.component';
18
+ import {
19
+ PARAM_ERROR_KEYS,
20
+ PARAM_INPUT_TYPE,
21
+ PARAM_LABEL_KEYS,
22
+ getSolicitedFields,
23
+ getSolicitedHintKey,
24
+ getSolicitedParams,
25
+ isSolicitedDataComplete,
26
+ isSolicitedHintSuccess,
27
+ type SolicitedField,
28
+ } from '@block-auth.io/core';
12
29
 
13
30
  // `handleParamChange`/`onValidationChange` (callback props en react) -> outputs
14
31
  // `paramChange`/`validationChange`: notificaciones puras de "algo cambió en la
@@ -24,116 +41,239 @@ export interface ParamChange {
24
41
  value: string;
25
42
  }
26
43
 
27
- const PARAM_LABEL_KEYS: Record<string, TextKey> = {
28
- 'users-firstname': 'firstName',
29
- 'users-lastname': 'lastName',
30
- 'users-secondlastname': 'secondLastName',
31
- 'users-email': 'email',
32
- 'users-phone': 'phone',
33
- };
34
-
35
- const PARAM_DISPLAY_ORDER = [
36
- 'users-firstname',
37
- 'users-lastname',
38
- 'users-secondlastname',
39
- 'users-email',
40
- 'users-phone',
41
- 'modules-kyc',
42
- ];
43
-
44
- const PARAM_INPUT_TYPE: Record<string, string> = {
45
- 'users-firstname': 'text',
46
- 'users-lastname': 'text',
47
- 'users-secondlastname': 'text',
48
- 'users-email': 'email',
49
- 'users-phone': 'tel',
50
- };
51
-
52
- const PARAM_VALIDATORS: Record<string, (v: string) => boolean> = {
53
- 'users-firstname': (v) => /^[A-Za-zÀ-ÖØ-öø-ÿ\s'-]{1,50}$/.test(v.trim()),
54
- 'users-lastname': (v) => /^[A-Za-zÀ-ÖØ-öø-ÿ\s'-]{1,50}$/.test(v.trim()),
55
- 'users-secondlastname': (v) => /^[A-Za-zÀ-ÖØ-öø-ÿ\s'-]{1,50}$/.test(v.trim()),
56
- 'users-email': (v) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim()),
57
- 'users-phone': (v) => /^\+?[\d\s\-().]{6,20}$/.test(v.trim()),
58
- };
59
-
60
- const PARAM_ERROR_KEYS: Record<string, TextKey> = {
61
- 'users-firstname': 'onlyLettersAllowed',
62
- 'users-lastname': 'onlyLettersAllowed',
63
- 'users-secondlastname': 'onlyLettersAllowed',
64
- 'users-email': 'enterValidEmail',
65
- 'users-phone': 'enterValidPhone',
66
- };
44
+ export interface ShareChange {
45
+ slug: string;
46
+ shared: boolean;
47
+ }
48
+
49
+ interface SolicitedFieldView extends SolicitedField {
50
+ isRequired: boolean;
51
+ isPending: boolean;
52
+ label: string;
53
+ inputId: string;
54
+ inputType: string;
55
+ error: string | null;
56
+ hint: string | null;
57
+ isHintSuccess: boolean;
58
+ showDecision: boolean;
59
+ describedBy: string | null;
60
+ isPhone: boolean;
61
+ }
67
62
 
68
63
  @Component({
69
64
  selector: 'ba-solicited-data-modal',
70
65
  standalone: true,
66
+ imports: [PhoneInputComponent],
71
67
  changeDetection: ChangeDetectionStrategy.OnPush,
72
68
  template: `
73
69
  <div
74
- class="bal-ba-transition-2 bas-box-border bas-flex bas-w-[334px] bas-flex-col bas-gap-3 bas-rounded-md bas-border-2 bas-border-solid bas-border-[var(--bal-primary-100)] bas-bg-[var(--bal-primary-10)] bas-px-4 bas-py-4 hover:bas-border-[var(--bal-primary-300)]"
70
+ class="bal-ba-glass-panel bal-ba-glass-pop bal-ba-glass-modal bas-box-border bas-w-[334px] bas-gap-4 bas-p-4"
75
71
  >
76
- @for (slug of visibleParams(); track slug) {
77
- <div class="bas-flex bas-flex-col bas-gap-1">
78
- <div class="bas-flex bas-items-center bas-justify-between">
79
- <label class="bas-text-xs bas-font-medium bas-text-slate-600">{{
80
- label(slug)
81
- }}</label>
82
- <span
83
- style="font-size: 10px"
84
- [class]="
85
- 'bas-rounded bas-px-1 bas-py-px bas-font-bold bas-uppercase bas-tracking-wide ' +
86
- (isRequired(slug)
87
- ? 'bas-bg-amber-100 bas-text-amber-600'
88
- : 'bas-bg-slate-100 bas-text-slate-400')
89
- "
90
- >
91
- {{
92
- isRequired(slug)
93
- ? requiredLabelTxtResolved()
94
- : optionalLabelTxtResolved()
95
- }}
96
- </span>
97
- </div>
98
- <div class="bas-relative">
99
- <input
100
- [type]="inputType(slug)"
101
- [value]="valueOf(slug)"
102
- [class]="
103
- 'bas-w-full bas-rounded bas-border bas-bg-white bas-px-2 bas-py-1 bas-text-sm bas-text-slate-700 bas-outline-none bas-transition-colors ' +
104
- (errorOf(slug)
105
- ? 'bas-border-red-400 bas-pr-7 focus:bas-border-red-500'
106
- : 'bas-border-slate-300 focus:bas-border-[var(--bal-primary-500)]')
107
- "
108
- (input)="onInput(slug, $event)"
109
- (blur)="onBlur(slug)"
110
- />
111
- @if (errorOf(slug)) {
72
+ @if (closable()) {
73
+ <div class="bal-ba-glass-modal-head">
74
+ <button
75
+ type="button"
76
+ id="solicited-data-close"
77
+ [attr.aria-label]="t('close')"
78
+ class="bal-ba-glass-close"
79
+ (click)="closed.emit()"
80
+ >
112
81
  <svg
113
- class="bas-absolute bas-right-2 bas-top-1/2 bas-h-4 bas-w-4 -bas-translate-y-1/2 bas-text-red-500"
114
- viewBox="0 0 24 24"
115
- fill="currentColor"
82
+ viewBox="0 0 10 10"
83
+ fill="none"
84
+ stroke="currentColor"
85
+ stroke-width="1.6"
86
+ stroke-linecap="round"
116
87
  aria-hidden="true"
117
88
  >
118
- <path
119
- d="M12 2 1 21h22L12 2Zm0 6c.55 0 1 .45 1 1v5a1 1 0 1 1-2 0V9c0-.55.45-1 1-1Zm0 9.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Z"
120
- />
89
+ <path d="M2 2l6 6M8 2 2 8" />
121
90
  </svg>
91
+ </button>
92
+ </div>
93
+ }
94
+ <div
95
+ #scroll
96
+ [class]="'bal-ba-glass-scroll' + (isScrollable() ? ' bal-ba-glass-scroll-overflow' : '')"
97
+ >
98
+ <div class="bal-ba-glass-stagger bas-flex bas-flex-col bas-gap-3">
99
+ @for (field of fields(); track field.slug) {
100
+ <div
101
+ [class]="'bal-ba-glass-field' + (field.isPending ? ' bal-ba-glass-field-pending' : '')"
102
+ >
103
+ <div class="bal-ba-glass-field-head">
104
+ <label [for]="field.inputId" class="bal-ba-glass-label">
105
+ <span>{{ field.label }}</span>
106
+ @if (field.isRequired) {
107
+ <span class="bal-ba-glass-label-required">{{ requiredLabelTxtResolved() }}</span>
108
+ } @else {
109
+ <span class="bal-ba-glass-label-optional">{{ optionalLabelTxtResolved() }}</span>
110
+ }
111
+ </label>
112
+ @if (withConsent()) {
113
+ <button
114
+ type="button"
115
+ role="switch"
116
+ [id]="'solicited-data-share-' + field.slug"
117
+ [attr.aria-checked]="field.isShared ? 'true' : 'false'"
118
+ [disabled]="field.isEmpty"
119
+ [attr.aria-label]="t('shareData') + ' ' + field.label"
120
+ class="bal-ba-glass-switch"
121
+ (click)="onShare(field)"
122
+ >
123
+ <span aria-hidden="true">{{ t('shareData') }}</span>
124
+ <span class="bal-ba-glass-switch-track" aria-hidden="true">
125
+ <span class="bal-ba-glass-switch-thumb"></span>
126
+ </span>
127
+ </button>
128
+ }
129
+ </div>
130
+ @if (field.isPhone) {
131
+ <ba-phone-input
132
+ #phoneInput
133
+ [inputId]="field.inputId"
134
+ [value]="field.inputValue"
135
+ [invalid]="!!field.error"
136
+ [describedBy]="field.describedBy"
137
+ (valueChange)="paramChange.emit({ slug: field.slug, value: $event })"
138
+ (blurred)="onBlur(field.slug)"
139
+ >
140
+ @if (field.inputValue) {
141
+ <button
142
+ type="button"
143
+ [attr.aria-label]="t('clearField') + ' ' + field.label"
144
+ class="bal-ba-glass-input-clear"
145
+ (click)="onClear(field.slug, phoneInput)"
146
+ >
147
+ <svg
148
+ viewBox="0 0 10 10"
149
+ fill="none"
150
+ stroke="currentColor"
151
+ stroke-width="1.8"
152
+ stroke-linecap="round"
153
+ aria-hidden="true"
154
+ >
155
+ <path d="M2 2l6 6M8 2 2 8" />
156
+ </svg>
157
+ </button>
158
+ }
159
+ </ba-phone-input>
160
+ } @else {
161
+ <div class="bal-ba-glass-input-wrap">
162
+ <input
163
+ #fieldInput
164
+ [id]="field.inputId"
165
+ [type]="field.inputType"
166
+ [value]="field.inputValue"
167
+ [attr.aria-invalid]="field.error ? 'true' : 'false'"
168
+ [attr.aria-describedby]="field.describedBy"
169
+ class="bal-ba-glass-input"
170
+ (input)="onInput(field.slug, $event)"
171
+ (blur)="onBlur(field.slug)"
172
+ />
173
+ @if (field.inputValue) {
174
+ <button
175
+ type="button"
176
+ [attr.aria-label]="t('clearField') + ' ' + field.label"
177
+ class="bal-ba-glass-input-clear"
178
+ (click)="onClear(field.slug, fieldInput)"
179
+ >
180
+ <svg
181
+ viewBox="0 0 10 10"
182
+ fill="none"
183
+ stroke="currentColor"
184
+ stroke-width="1.8"
185
+ stroke-linecap="round"
186
+ aria-hidden="true"
187
+ >
188
+ <path d="M2 2l6 6M8 2 2 8" />
189
+ </svg>
190
+ </button>
191
+ }
192
+ </div>
193
+ }
194
+ @if (field.error) {
195
+ <p [id]="field.inputId + '-error'" class="bal-ba-glass-error">
196
+ {{ field.error }}
197
+ </p>
198
+ } @if (field.showDecision) {
199
+ <div class="bal-ba-glass-decision">
200
+ @if (field.hint) {
201
+ <p
202
+ [id]="field.inputId + '-hint'"
203
+ [class]="
204
+ 'bal-ba-glass-hint' +
205
+ (field.isHintSuccess ? ' bal-ba-glass-hint-success' : '')
206
+ "
207
+ >
208
+ {{ field.hint }}
209
+ </p>
210
+ }
211
+ <div class="bal-ba-glass-decision-actions">
212
+ <button
213
+ type="button"
214
+ [id]="'solicited-data-undo-' + field.slug"
215
+ class="bal-ba-glass-chip-btn"
216
+ (click)="undoParam.emit(field.slug)"
217
+ >
218
+ {{ t('undoChange') }}
219
+ </button>
220
+ @if (!field.isSaved) {
221
+ <button
222
+ type="button"
223
+ [id]="'solicited-data-save-' + field.slug"
224
+ [disabled]="!field.isValid && !field.isEmpty"
225
+ class="bal-ba-glass-chip-btn bal-ba-glass-chip-btn-primary"
226
+ (click)="saveParam.emit(field.slug)"
227
+ >
228
+ {{ t('saveData') }}
229
+ </button>
230
+ }
231
+ </div>
232
+ </div>
233
+ } @else { @if (field.hint) {
234
+ <p
235
+ [id]="field.inputId + '-hint'"
236
+ [class]="
237
+ 'bal-ba-glass-hint' +
238
+ (field.isHintSuccess ? ' bal-ba-glass-hint-success' : '')
239
+ "
240
+ >
241
+ {{ field.hint }}
242
+ </p>
243
+ } }
244
+ </div>
122
245
  }
123
246
  </div>
124
- @if (errorOf(slug)) {
125
- <p
126
- style="font-size: 10px"
127
- class="bas-m-0 bas-px-2 bas-font-medium bas-text-red-500"
128
- >
129
- {{ errorOf(slug) }}
130
- </p>
131
- }
132
247
  </div>
133
- } @if (!allRequiredFilled()) {
134
- <p class="bas-m-0 bas-text-center bas-text-xs bas-text-amber-600">
135
- {{ fillRequiredTxtResolved() }}
248
+ @if (!allRequiredFilled()) {
249
+ <p id="solicited-data-notice" class="bal-ba-glass-notice bal-ba-glass-rise">
250
+ <svg
251
+ viewBox="0 0 16 16"
252
+ fill="none"
253
+ stroke="currentColor"
254
+ stroke-width="1.5"
255
+ stroke-linecap="round"
256
+ aria-hidden="true"
257
+ class="bal-ba-glass-notice-icon"
258
+ >
259
+ <circle cx="8" cy="8" r="6.25" />
260
+ <path d="M8 7.25v3.5M8 5.1v.01" />
261
+ </svg>
262
+ <span>{{ fillRequiredTxtResolved() }}</span>
263
+ </p>
264
+ } @if (errorTxt()) {
265
+ <p role="alert" class="bal-ba-glass-notice bal-ba-glass-notice-error bal-ba-glass-rise">
266
+ {{ errorTxt() }}
136
267
  </p>
268
+ } @if (acceptable()) {
269
+ <button
270
+ type="button"
271
+ id="solicited-data-accept"
272
+ class="bal-ba-glass-action bal-ba-glass-action-primary"
273
+ (click)="accepted.emit()"
274
+ >
275
+ {{ acceptTxt() ?? t('acceptData') }}
276
+ </button>
137
277
  }
138
278
  </div>
139
279
  `,
@@ -141,76 +281,131 @@ const PARAM_ERROR_KEYS: Record<string, TextKey> = {
141
281
  export class SolicitedDataModalComponent {
142
282
  requiredParams = input<string[]>([]);
143
283
  optionalParams = input<string[]>([]);
284
+ params = input<TxnDataParam[] | null>();
144
285
  userParamValues = input<Record<string, string>>({});
286
+ sharedParams = input<Record<string, boolean>>({});
287
+ savedParams = input<Record<string, string>>({});
145
288
  requiredLabelTxt = input<string>();
146
289
  optionalLabelTxt = input<string>();
147
290
  fillRequiredTxt = input<string>();
291
+ errorTxt = input<string>();
292
+ closable = input(false);
293
+ acceptable = input(false);
294
+ acceptTxt = input<string>();
148
295
 
149
296
  paramChange = output<ParamChange>();
297
+ shareChange = output<ShareChange>();
298
+ saveParam = output<string>();
299
+ undoParam = output<string>();
150
300
  validationChange = output<boolean>();
301
+ closed = output<void>();
302
+ accepted = output<void>();
151
303
 
152
304
  private readonly locale = injectStore($locale);
153
305
  protected t = (key: TextKey): string => translate(key, this.locale());
154
306
 
155
307
  protected readonly requiredLabelTxtResolved = computed(
156
- () => this.requiredLabelTxt() ?? this.t('required'),
308
+ () => this.requiredLabelTxt() ?? this.t('required')
157
309
  );
158
310
  protected readonly optionalLabelTxtResolved = computed(
159
- () => this.optionalLabelTxt() ?? this.t('optional'),
311
+ () => this.optionalLabelTxt() ?? this.t('optional')
160
312
  );
313
+ protected readonly withConsent = computed(() => !!this.params());
161
314
  protected readonly fillRequiredTxtResolved = computed(
162
- () => this.fillRequiredTxt() ?? this.t('fillRequiredFields'),
315
+ () =>
316
+ this.fillRequiredTxt() ??
317
+ this.t(this.withConsent() ? 'completeRequiredData' : 'fillRequiredFields')
163
318
  );
164
319
 
165
320
  private readonly touched = signal<Record<string, boolean>>({});
321
+ private readonly scroll = viewChild.required<ElementRef<HTMLDivElement>>('scroll');
322
+ protected readonly isScrollable = signal(false);
323
+
324
+ private readonly solicitedFields = computed(() =>
325
+ getSolicitedFields(
326
+ getSolicitedParams({
327
+ params: this.params(),
328
+ requiredParams: this.requiredParams(),
329
+ optionalParams: this.optionalParams(),
330
+ }),
331
+ {
332
+ values: this.userParamValues(),
333
+ shared: this.sharedParams(),
334
+ saved: this.savedParams(),
335
+ },
336
+ this.withConsent()
337
+ )
338
+ );
166
339
 
167
340
  protected readonly allRequiredFilled = computed(() =>
168
- this.requiredParams().every((slug) => {
169
- if (slug === 'modules-kyc') return true;
170
- const value = this.userParamValues()[slug]?.trim();
171
- if (!value) return false;
172
- const validate = PARAM_VALIDATORS[slug];
173
- return !validate || validate(value);
174
- }),
341
+ isSolicitedDataComplete(this.solicitedFields())
175
342
  );
176
343
 
177
- protected readonly visibleParams = computed(() =>
178
- PARAM_DISPLAY_ORDER.filter(
179
- (slug) =>
180
- slug !== 'modules-kyc' &&
181
- (this.requiredParams().includes(slug) ||
182
- this.optionalParams().includes(slug)),
183
- ),
344
+ protected readonly fields = computed<SolicitedFieldView[]>(() =>
345
+ this.solicitedFields().map((field) => {
346
+ const labelKey = PARAM_LABEL_KEYS[field.slug];
347
+ const label = labelKey ? this.t(labelKey) : field.slug;
348
+ const inputId = `solicited-data-${field.slug}`;
349
+ const errorKey = PARAM_ERROR_KEYS[field.slug];
350
+ const error =
351
+ this.touched()[field.slug] &&
352
+ field.inputValue.trim() &&
353
+ !field.isValid &&
354
+ errorKey
355
+ ? this.t(errorKey)
356
+ : null;
357
+ const showDecision = this.withConsent() && field.isChanged;
358
+ const hintKey = getSolicitedHintKey(field, this.withConsent());
359
+ const hint = hintKey ? this.t(hintKey) : null;
360
+ return {
361
+ ...field,
362
+ isRequired: field.mode === 'required',
363
+ isPending: field.mode === 'required' && !field.isComplete,
364
+ label,
365
+ inputId,
366
+ inputType: PARAM_INPUT_TYPE[field.slug] || 'text',
367
+ isPhone: PARAM_INPUT_TYPE[field.slug] === 'tel',
368
+ error,
369
+ hint,
370
+ isHintSuccess: isSolicitedHintSuccess(field),
371
+ showDecision,
372
+ describedBy: error ? `${inputId}-error` : hint ? `${inputId}-hint` : null,
373
+ };
374
+ })
184
375
  );
185
376
 
186
377
  constructor() {
187
378
  effect(() => {
188
379
  this.validationChange.emit(this.allRequiredFilled());
189
380
  });
190
- }
191
381
 
192
- isRequired = (slug: string): boolean => this.requiredParams().includes(slug);
193
- label = (slug: string): string => {
194
- const key = PARAM_LABEL_KEYS[slug];
195
- return key ? this.t(key) : slug;
196
- };
197
- inputType = (slug: string): string => PARAM_INPUT_TYPE[slug] || 'text';
198
- valueOf = (slug: string): string => this.userParamValues()[slug] || '';
199
-
200
- private getError(slug: string, value: string): string | null {
201
- if (!value) return null;
202
- const validate = PARAM_VALIDATORS[slug];
203
- if (validate && !validate(value)) return this.t(PARAM_ERROR_KEYS[slug]);
204
- return null;
382
+ const destroyRef = inject(DestroyRef);
383
+ afterNextRender(() => {
384
+ const el = this.scroll().nativeElement;
385
+ const update = () => this.isScrollable.set(el.scrollHeight > el.clientHeight + 1);
386
+ update();
387
+ if (typeof ResizeObserver === 'undefined') return;
388
+ const observer = new ResizeObserver(update);
389
+ observer.observe(el);
390
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
391
+ destroyRef.onDestroy(() => observer.disconnect());
392
+ });
205
393
  }
206
394
 
207
- errorOf = (slug: string): string | null =>
208
- this.touched()[slug] ? this.getError(slug, this.valueOf(slug)) : null;
209
-
210
395
  onBlur(slug: string): void {
211
396
  this.touched.update((prev) => ({ ...prev, [slug]: true }));
212
397
  }
213
398
 
399
+ onShare(field: SolicitedField): void {
400
+ if (field.isEmpty) return;
401
+ this.shareChange.emit({ slug: field.slug, shared: !field.isShared });
402
+ }
403
+
404
+ onClear(slug: string, inputEl: { focus: () => void }): void {
405
+ this.paramChange.emit({ slug, value: '' });
406
+ inputEl.focus();
407
+ }
408
+
214
409
  onInput(slug: string, event: Event): void {
215
410
  this.paramChange.emit({
216
411
  slug,
@@ -25,11 +25,11 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core
25
25
  >
26
26
  }
27
27
  <div class="bas-flex bas-w-full bas-items-center bas-justify-center bas-gap-3">
28
- <span class="bal-ba-transition-2 bas-h-px bas-flex-1 bas-bg-[var(--bal-primary-300)] group-hover:bas-bg-[var(--bal-primary-500)]"></span>
28
+ <span class="bal-ba-glass-divider bas-h-px bas-flex-1"></span>
29
29
  <p class="bal-ba-transition-2 bas-m-0 bas-cursor-default bas-text-center bas-text-lg bas-text-[var(--bal-primary-700)] group-hover:bas-text-[var(--bal-primary-900)]">
30
30
  {{ txnType() }}
31
31
  </p>
32
- <span class="bal-ba-transition-2 bas-h-px bas-flex-1 bas-bg-[var(--bal-primary-300)] group-hover:bas-bg-[var(--bal-primary-500)]"></span>
32
+ <span class="bal-ba-glass-divider bal-ba-glass-divider-end bas-h-px bas-flex-1"></span>
33
33
  </div>
34
34
  @if (txnsLength() > 1) {
35
35
  <span