@nauticana/sail 1.1.6 → 1.1.7
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.
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/component/dashboard/action_center.html +24 -0
- package/src/component/dashboard/action_center.ts +27 -0
- package/src/component/dashboard/dashboard_shell.html +13 -0
- package/src/component/dashboard/dashboard_shell.ts +17 -0
- package/src/component/dashboard/data_card.html +37 -0
- package/src/component/dashboard/data_card.ts +41 -0
- package/src/component/dashboard/entity_selector.html +8 -0
- package/src/component/dashboard/entity_selector.ts +48 -0
- package/src/component/dashboard/locked_overlay.html +8 -0
- package/src/component/dashboard/locked_overlay.ts +32 -0
- package/src/component/dashboard/verification_flow.html +32 -0
- package/src/component/dashboard/verification_flow.ts +36 -0
- package/src/index.ts +7 -0
- package/src/model/dashboard.ts +24 -0
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
18
18
|
| **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
|
|
19
19
|
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
|
|
20
20
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
21
|
+
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
21
22
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
22
23
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
23
24
|
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
@@ -750,6 +751,25 @@ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idTok
|
|
|
750
751
|
|
|
751
752
|
For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
|
|
752
753
|
|
|
754
|
+
## Dashboard components
|
|
755
|
+
|
|
756
|
+
Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
|
|
757
|
+
|
|
758
|
+
- **`DashboardShellComponent`** (`sail-dashboard-shell`) — responsive card-grid layout. `[heading]` + a `[dashboardActions]` projection slot for the header; project cards into the default slot. Styles: `.dashboard-shell` / `.dashboard-header` / `.dashboard-grid`.
|
|
759
|
+
- **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade` / `retry`.
|
|
760
|
+
- **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
|
|
761
|
+
- **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
|
|
762
|
+
- **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
|
|
763
|
+
- **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back.
|
|
764
|
+
|
|
765
|
+
```html
|
|
766
|
+
<sail-data-card heading="Trends" [state]="state()" description="Weekly visibility trend" (upgrade)="goUpgrade()">
|
|
767
|
+
<ng-template><canvas baseChart [data]="chartData()" [type]="'line'"></canvas></ng-template>
|
|
768
|
+
</sail-data-card>
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Models: `DataCardState`, `ActionItem`, `EntityOption`, `VerificationStep`.
|
|
772
|
+
|
|
753
773
|
## Adding a new domain table
|
|
754
774
|
|
|
755
775
|
When your project owns a table that isn't part of basis (e.g. `favorite_location`, `support_ticket`, `vehicle`), follow the pattern below so the row plays nicely with keel's generic CRUD endpoints and the sail `<table-edit>` / `<table-list>` components.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<section class="action-center">
|
|
2
|
+
@if (heading()) { <h2 class="action-center-title">{{ heading() }}</h2> }
|
|
3
|
+
@if (sorted().length === 0) {
|
|
4
|
+
<p class="action-center-empty">{{ emptyMessage() }}</p>
|
|
5
|
+
} @else {
|
|
6
|
+
<ul class="action-center-list">
|
|
7
|
+
@for (a of sorted(); track a.id) {
|
|
8
|
+
<li class="action-item" [class.action-done]="a.done" [attr.data-priority]="a.priority ?? 0">
|
|
9
|
+
<div class="action-body">
|
|
10
|
+
<span class="action-title">{{ a.title }}</span>
|
|
11
|
+
<span class="action-reason">{{ a.reason }}</span>
|
|
12
|
+
</div>
|
|
13
|
+
@if (a.done) {
|
|
14
|
+
<span class="action-complete">
|
|
15
|
+
<mat-icon aria-hidden="true">check_circle</mat-icon><span class="sr-only">Done</span>
|
|
16
|
+
</span>
|
|
17
|
+
} @else {
|
|
18
|
+
<button matButton="elevated" type="button" class="primary" (click)="act.emit(a)">{{ a.ctaLabel }}</button>
|
|
19
|
+
}
|
|
20
|
+
</li>
|
|
21
|
+
}
|
|
22
|
+
</ul>
|
|
23
|
+
}
|
|
24
|
+
</section>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
4
|
+
import { ActionItem } from '../../model/dashboard';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prioritized next-best-action list (guided completion, FR-008). Renders each
|
|
8
|
+
* action with its reason and a single CTA, sorted by descending priority;
|
|
9
|
+
* completed items show a done state instead of a button. Pure presentational —
|
|
10
|
+
* the host performs the action on `act`.
|
|
11
|
+
*/
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-action-center',
|
|
14
|
+
templateUrl: './action_center.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [MatButtonModule, MatIconModule],
|
|
18
|
+
})
|
|
19
|
+
export class ActionCenterComponent {
|
|
20
|
+
readonly actions = input<ActionItem[]>([]);
|
|
21
|
+
readonly heading = input<string>('Recommended actions');
|
|
22
|
+
readonly emptyMessage = input<string>("You're all caught up.");
|
|
23
|
+
readonly act = output<ActionItem>();
|
|
24
|
+
|
|
25
|
+
readonly sorted = computed<ActionItem[]>(() =>
|
|
26
|
+
[...this.actions()].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)));
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<section class="dashboard-shell">
|
|
2
|
+
@if (heading()) {
|
|
3
|
+
<header class="dashboard-header">
|
|
4
|
+
<h1 class="dashboard-title">{{ heading() }}</h1>
|
|
5
|
+
<div class="dashboard-actions">
|
|
6
|
+
<ng-content select="[dashboardActions]"></ng-content>
|
|
7
|
+
</div>
|
|
8
|
+
</header>
|
|
9
|
+
}
|
|
10
|
+
<div class="dashboard-grid">
|
|
11
|
+
<ng-content></ng-content>
|
|
12
|
+
</div>
|
|
13
|
+
</section>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Responsive card-grid dashboard shell. Pure layout: an optional heading row with
|
|
5
|
+
* a projected actions slot ([dashboardActions]), then a grid of projected cards.
|
|
6
|
+
* Ships no CSS — the app styles `.dashboard-shell` / `.dashboard-header` /
|
|
7
|
+
* `.dashboard-grid`.
|
|
8
|
+
*/
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'sail-dashboard-shell',
|
|
11
|
+
templateUrl: './dashboard_shell.html',
|
|
12
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
|
+
encapsulation: ViewEncapsulation.None,
|
|
14
|
+
})
|
|
15
|
+
export class DashboardShellComponent {
|
|
16
|
+
readonly heading = input<string>('');
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<section class="data-card" [attr.data-state]="state()" [attr.aria-busy]="state() === 'loading'">
|
|
2
|
+
@if (heading()) { <h2 class="data-card-title">{{ heading() }}</h2> }
|
|
3
|
+
|
|
4
|
+
@switch (state()) {
|
|
5
|
+
@case ('loading') {
|
|
6
|
+
<div class="data-card-loading"><mat-spinner diameter="28"></mat-spinner></div>
|
|
7
|
+
}
|
|
8
|
+
@case ('ready') {
|
|
9
|
+
<p class="sr-only">{{ description() }}</p>
|
|
10
|
+
@if (chart(); as tpl) { <ng-container [ngTemplateOutlet]="tpl"></ng-container> }
|
|
11
|
+
@if (tableRows().length) {
|
|
12
|
+
<table class="sr-only data-card-table">
|
|
13
|
+
<caption>{{ description() }}</caption>
|
|
14
|
+
@if (tableHeaders().length) {
|
|
15
|
+
<thead><tr>@for (h of tableHeaders(); track $index) { <th>{{ h }}</th> }</tr></thead>
|
|
16
|
+
}
|
|
17
|
+
<tbody>
|
|
18
|
+
@for (row of tableRows(); track $index) {
|
|
19
|
+
<tr>@for (cell of row; track $index) { <td>{{ cell }}</td> }</tr>
|
|
20
|
+
}
|
|
21
|
+
</tbody>
|
|
22
|
+
</table>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
@case ('empty') { <p class="data-card-empty">{{ emptyMessage() }}</p> }
|
|
26
|
+
@case ('error') {
|
|
27
|
+
<p class="data-card-error" role="alert">{{ errorMessage() }}</p>
|
|
28
|
+
<button matButton type="button" (click)="retry.emit()">Retry</button>
|
|
29
|
+
}
|
|
30
|
+
@case ('locked') {
|
|
31
|
+
<div class="data-card-locked" role="group" [attr.aria-label]="(heading() || 'Insight') + ' — locked'">
|
|
32
|
+
<p class="locked-message">{{ lockedMessage() }}</p>
|
|
33
|
+
<button matButton="elevated" type="button" class="primary" (click)="upgrade.emit()">{{ ctaLabel() }}</button>
|
|
34
|
+
</div>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
</section>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, contentChild, input, output } from '@angular/core';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
5
|
+
import { DataCardState } from '../../model/dashboard';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Card chrome for a single data visualization. The app supplies the chart itself
|
|
9
|
+
* as an <ng-template> (bring your own chart library — keel apps use ng2-charts);
|
|
10
|
+
* the card instantiates it ONLY in the 'ready' state, so during
|
|
11
|
+
* loading/empty/error/locked the chart never exists (no hidden canvas, no data
|
|
12
|
+
* leak when locked).
|
|
13
|
+
*
|
|
14
|
+
* Accessibility: `description` is a required text alternative announced in place
|
|
15
|
+
* of the visual chart; `tableHeaders`/`tableRows`, when supplied, render a
|
|
16
|
+
* visually-hidden data table (`.sr-only`, app-styled) as the non-visual equivalent.
|
|
17
|
+
*/
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'sail-data-card',
|
|
20
|
+
templateUrl: './data_card.html',
|
|
21
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
22
|
+
encapsulation: ViewEncapsulation.None,
|
|
23
|
+
imports: [NgTemplateOutlet, MatButtonModule, MatProgressSpinnerModule],
|
|
24
|
+
})
|
|
25
|
+
export class DataCardComponent {
|
|
26
|
+
readonly heading = input<string>('');
|
|
27
|
+
readonly state = input<DataCardState>('loading');
|
|
28
|
+
/** Required text alternative for screen readers — the non-visual equivalent. */
|
|
29
|
+
readonly description = input.required<string>();
|
|
30
|
+
readonly errorMessage = input<string>('Could not load this data.');
|
|
31
|
+
readonly emptyMessage = input<string>('No data yet.');
|
|
32
|
+
readonly lockedMessage = input<string>('Upgrade to unlock this insight.');
|
|
33
|
+
readonly ctaLabel = input<string>('Upgrade');
|
|
34
|
+
readonly tableHeaders = input<string[]>([]);
|
|
35
|
+
readonly tableRows = input<(string | number)[][]>([]);
|
|
36
|
+
readonly upgrade = output<void>();
|
|
37
|
+
readonly retry = output<void>();
|
|
38
|
+
|
|
39
|
+
/** The chart, provided as a single <ng-template>; instantiated only when ready. */
|
|
40
|
+
readonly chart = contentChild(TemplateRef);
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<mat-form-field appearance="outline" class="entity-selector">
|
|
2
|
+
<mat-label>{{ label() }}</mat-label>
|
|
3
|
+
<mat-select [value]="value()" (selectionChange)="onChange($event.value)">
|
|
4
|
+
@for (e of entities(); track e.id) {
|
|
5
|
+
<mat-option [value]="e.id" [disabled]="e.active === false">{{ e.label }}</mat-option>
|
|
6
|
+
}
|
|
7
|
+
</mat-select>
|
|
8
|
+
</mat-form-field>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, effect, input, output, signal } from '@angular/core';
|
|
2
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
3
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
4
|
+
import { EntityOption } from '../../model/dashboard';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scope selector for the active entity (e.g. business). Resolves the selection
|
|
8
|
+
* from `selected` when valid, else the first active option, and emits it — so the
|
|
9
|
+
* host's dependent data loads for a sensible default without extra wiring.
|
|
10
|
+
*/
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'sail-entity-selector',
|
|
13
|
+
templateUrl: './entity_selector.html',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
encapsulation: ViewEncapsulation.None,
|
|
16
|
+
imports: [MatFormFieldModule, MatSelectModule],
|
|
17
|
+
})
|
|
18
|
+
export class EntitySelectorComponent {
|
|
19
|
+
readonly entities = input<EntityOption[]>([]);
|
|
20
|
+
readonly selected = input<string | undefined>(undefined);
|
|
21
|
+
readonly label = input<string>('Business');
|
|
22
|
+
readonly selectionChange = output<string>();
|
|
23
|
+
|
|
24
|
+
readonly value = signal<string | undefined>(undefined);
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
// Explicit selection wins; else keep the current pick if still valid; else the
|
|
28
|
+
// first active entity. Emit whenever the resolved value actually changes.
|
|
29
|
+
effect(() => {
|
|
30
|
+
const opts = this.entities();
|
|
31
|
+
const want = this.selected();
|
|
32
|
+
const cur = this.value();
|
|
33
|
+
const resolved =
|
|
34
|
+
want && opts.some((e) => e.id === want) ? want
|
|
35
|
+
: cur && opts.some((e) => e.id === cur) ? cur
|
|
36
|
+
: opts.find((e) => e.active !== false)?.id;
|
|
37
|
+
if (resolved && resolved !== cur) {
|
|
38
|
+
this.value.set(resolved);
|
|
39
|
+
this.selectionChange.emit(resolved);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onChange(id: string): void {
|
|
45
|
+
this.value.set(id);
|
|
46
|
+
this.selectionChange.emit(id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@if (locked()) {
|
|
2
|
+
<div class="locked-overlay" role="group" [attr.aria-label]="(feature() || 'Feature') + ' — locked'">
|
|
3
|
+
<p class="locked-message">{{ message() }}</p>
|
|
4
|
+
<button matButton="elevated" type="button" class="primary" (click)="upgrade.emit()">{{ ctaLabel() }}</button>
|
|
5
|
+
</div>
|
|
6
|
+
} @else if (content(); as tpl) {
|
|
7
|
+
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, contentChild, input, output } from '@angular/core';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Entitlement gate that renders an upgrade prompt over a feature. The protected
|
|
7
|
+
* content is supplied as an <ng-template> and is instantiated ONLY when unlocked —
|
|
8
|
+
* so when locked there is nothing behind it: no DOM nodes, no chart canvas, no
|
|
9
|
+
* data binding, no triggered network call. This is the strict free-tier rule
|
|
10
|
+
* (DASHBOARD.md): a locked card provably exposes no data.
|
|
11
|
+
*
|
|
12
|
+
* <sail-locked-overlay [locked]="!entitled()" feature="Trends" (upgrade)="goUpgrade()">
|
|
13
|
+
* <ng-template><canvas baseChart ...></canvas></ng-template>
|
|
14
|
+
* </sail-locked-overlay>
|
|
15
|
+
*/
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sail-locked-overlay',
|
|
18
|
+
templateUrl: './locked_overlay.html',
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
encapsulation: ViewEncapsulation.None,
|
|
21
|
+
imports: [NgTemplateOutlet, MatButtonModule],
|
|
22
|
+
})
|
|
23
|
+
export class LockedOverlayComponent {
|
|
24
|
+
readonly locked = input.required<boolean>();
|
|
25
|
+
readonly feature = input<string>('');
|
|
26
|
+
readonly message = input<string>('Upgrade to unlock this feature.');
|
|
27
|
+
readonly ctaLabel = input<string>('Upgrade');
|
|
28
|
+
readonly upgrade = output<void>();
|
|
29
|
+
|
|
30
|
+
/** The protected content, provided as a single <ng-template> by the consumer. */
|
|
31
|
+
readonly content = contentChild(TemplateRef);
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<div class="verification-flow" [attr.data-step]="step()">
|
|
2
|
+
@switch (step()) {
|
|
3
|
+
@case ('verified') {
|
|
4
|
+
<p class="verification-success" role="status">Verified.</p>
|
|
5
|
+
}
|
|
6
|
+
@case ('idle') {
|
|
7
|
+
<p class="verification-intro">
|
|
8
|
+
Verify ownership@if (target()) { of {{ target() }} } to unlock trusted features.
|
|
9
|
+
</p>
|
|
10
|
+
<button matButton="elevated" type="button" class="primary" (click)="requestVerification.emit()">
|
|
11
|
+
Start verification
|
|
12
|
+
</button>
|
|
13
|
+
}
|
|
14
|
+
@default {
|
|
15
|
+
<p class="verification-intro">Enter the code we sent@if (target()) { to {{ target() }} }.</p>
|
|
16
|
+
<mat-form-field appearance="outline">
|
|
17
|
+
<mat-label>Verification code</mat-label>
|
|
18
|
+
<input matInput #codeInput name="code" autocomplete="one-time-code"
|
|
19
|
+
[value]="code()" (input)="code.set(codeInput.value)"
|
|
20
|
+
[disabled]="step() === 'verifying'">
|
|
21
|
+
</mat-form-field>
|
|
22
|
+
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
23
|
+
<div class="verification-actions">
|
|
24
|
+
<button matButton="elevated" type="button" class="primary"
|
|
25
|
+
[disabled]="step() === 'verifying' || !code().trim()" (click)="submit()">
|
|
26
|
+
@if (step() === 'verifying') { Verifying… } @else { Confirm }
|
|
27
|
+
</button>
|
|
28
|
+
<button matButton type="button" (click)="requestVerification.emit()">Resend code</button>
|
|
29
|
+
</div>
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, output, signal } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
4
|
+
import { MatInputModule } from '@angular/material/input';
|
|
5
|
+
import { VerificationStep } from '../../model/dashboard';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Self-service verification flow — a sibling to the 2FA verify component, but
|
|
9
|
+
* backend-agnostic: it owns the request → enter-code → confirmed UI and emits
|
|
10
|
+
* `requestVerification` / `confirmCode`, while the host wires the actual endpoints
|
|
11
|
+
* and feeds `step` + `errorMessage` back (daxoom wires business verify/confirm).
|
|
12
|
+
*/
|
|
13
|
+
@Component({
|
|
14
|
+
selector: 'sail-verification-flow',
|
|
15
|
+
templateUrl: './verification_flow.html',
|
|
16
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
|
+
encapsulation: ViewEncapsulation.None,
|
|
18
|
+
imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
19
|
+
})
|
|
20
|
+
export class VerificationFlowComponent {
|
|
21
|
+
readonly step = input<VerificationStep>('idle');
|
|
22
|
+
readonly errorMessage = input<string>('');
|
|
23
|
+
/** The email/phone/identifier being verified, shown in the copy. */
|
|
24
|
+
readonly target = input<string>('');
|
|
25
|
+
readonly requestVerification = output<void>();
|
|
26
|
+
readonly confirmCode = output<string>();
|
|
27
|
+
|
|
28
|
+
readonly code = signal('');
|
|
29
|
+
|
|
30
|
+
submit(): void {
|
|
31
|
+
const c = this.code().trim();
|
|
32
|
+
if (c) {
|
|
33
|
+
this.confirmCode.emit(c);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,15 @@ export * from './config';
|
|
|
2
2
|
export * from './model/appdata';
|
|
3
3
|
export * from './model/auth';
|
|
4
4
|
export * from './model/common';
|
|
5
|
+
export * from './model/dashboard';
|
|
5
6
|
export * from './model/decorator';
|
|
6
7
|
export * from './component/abstract/base_table';
|
|
8
|
+
export * from './component/dashboard/dashboard_shell';
|
|
9
|
+
export * from './component/dashboard/data_card';
|
|
10
|
+
export * from './component/dashboard/locked_overlay';
|
|
11
|
+
export * from './component/dashboard/action_center';
|
|
12
|
+
export * from './component/dashboard/entity_selector';
|
|
13
|
+
export * from './component/dashboard/verification_flow';
|
|
7
14
|
export * from './component/abstract/base_form';
|
|
8
15
|
export * from './component/abstract/base_view';
|
|
9
16
|
export * from './component/abstract/base_async';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Lifecycle state of a data/chart card (sail-data-card). */
|
|
2
|
+
export type DataCardState = 'loading' | 'ready' | 'empty' | 'error' | 'locked';
|
|
3
|
+
|
|
4
|
+
/** A prioritized, completable next-best-action (sail-action-center, FR-008). */
|
|
5
|
+
export interface ActionItem {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
ctaLabel: string;
|
|
10
|
+
/** Higher sorts first; defaults to 0. */
|
|
11
|
+
priority?: number;
|
|
12
|
+
done?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One selectable scope entity (e.g. a business) for sail-entity-selector. */
|
|
16
|
+
export interface EntityOption {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
/** Defaults to true; the selector defaults to the first active entity. */
|
|
20
|
+
active?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Step of the self-service verification flow (sail-verification-flow). */
|
|
24
|
+
export type VerificationStep = 'idle' | 'requested' | 'verifying' | 'verified' | 'error';
|