@nauticana/sail 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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
  "files": [
@@ -0,0 +1,159 @@
1
+ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation, inject, signal } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatCardModule } from '@angular/material/card';
5
+ import { MatIconModule } from '@angular/material/icon';
6
+ import { BaseAsync } from '../abstract/base_async';
7
+ import { UserPaymentMethodService } from '../../service/user_payment_method.service';
8
+ import { UserPaymentMethod } from '../../model/appdata';
9
+
10
+ /**
11
+ * End-user saved payment methods.
12
+ *
13
+ * Drop-in screen that lists the caller's cards/wallets, with set-default
14
+ * + delete actions. Inherits loading/error state from BaseAsync.
15
+ *
16
+ * Customise the heading and "add" CTA via inputs; emit (addClicked) so
17
+ * the consumer routes to its own SetupIntent flow (sail does not bundle
18
+ * a SetupIntent UI today — providers vary too much).
19
+ *
20
+ * Selector: <sail-user-payment-methods>
21
+ */
22
+ @Component({
23
+ selector: 'sail-user-payment-methods',
24
+ changeDetection: ChangeDetectionStrategy.OnPush,
25
+ encapsulation: ViewEncapsulation.None,
26
+ imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule],
27
+ template: `
28
+ <div class="user-payment-methods">
29
+ <h2 class="user-payment-methods__title">{{ title }}</h2>
30
+
31
+ @if (showAdd) {
32
+ <button mat-raised-button color="accent"
33
+ class="user-payment-methods__add"
34
+ (click)="addClicked.emit()">
35
+ <mat-icon>add</mat-icon> {{ addLabel }}
36
+ </button>
37
+ }
38
+
39
+ @if (methods().length === 0 && !loading()) {
40
+ <p class="user-payment-methods__empty">{{ emptyLabel }}</p>
41
+ }
42
+
43
+ @for (m of methods(); track m.Id) {
44
+ <mat-card class="user-payment-methods__item">
45
+ <mat-card-content>
46
+ <div class="user-payment-methods__row">
47
+ <div class="user-payment-methods__info">
48
+ <mat-icon>{{ iconFor(m) }}</mat-icon>
49
+ <span class="user-payment-methods__brand">{{ m.Brand || m.MethodType }}</span>
50
+ @if (m.LastFour) {
51
+ <span class="user-payment-methods__last4">•••• {{ m.LastFour }}</span>
52
+ }
53
+ @if (m.IsDefault) {
54
+ <span class="user-payment-methods__default">Default</span>
55
+ }
56
+ </div>
57
+ <div class="user-payment-methods__actions">
58
+ @if (!m.IsDefault) {
59
+ <button mat-button (click)="setDefault(m)">Set default</button>
60
+ }
61
+ <button mat-icon-button (click)="remove(m)" aria-label="Delete payment method">
62
+ <mat-icon>delete</mat-icon>
63
+ </button>
64
+ </div>
65
+ </div>
66
+ </mat-card-content>
67
+ </mat-card>
68
+ }
69
+
70
+ @if (errorMsg()) {
71
+ <p class="user-payment-methods__error">{{ errorMsg() }}</p>
72
+ }
73
+ </div>
74
+ `,
75
+ styles: `
76
+ .user-payment-methods { display: flex; flex-direction: column; gap: 12px; }
77
+ .user-payment-methods__title { margin: 0; }
78
+ .user-payment-methods__add { align-self: flex-start; }
79
+ .user-payment-methods__empty { color: var(--mat-app-on-surface-variant, #555); }
80
+ .user-payment-methods__item { background: var(--mat-app-surface, #fff); }
81
+ .user-payment-methods__row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
82
+ .user-payment-methods__info { display: flex; align-items: center; gap: 8px; }
83
+ .user-payment-methods__brand { font-weight: 600; }
84
+ .user-payment-methods__last4 { color: var(--mat-app-on-surface-variant, #555); }
85
+ .user-payment-methods__default { color: var(--mat-app-primary, #1976d2); margin-left: 8px; font-size: 12px; font-weight: 600; }
86
+ .user-payment-methods__actions { display: flex; align-items: center; gap: 4px; }
87
+ .user-payment-methods__error { color: var(--mat-app-error, #b00020); font-size: 13px; margin: 0; }
88
+ `,
89
+ })
90
+ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
91
+ @Input() title = 'Payment Methods';
92
+ @Input() addLabel = 'Add Payment Method';
93
+ @Input() emptyLabel = 'No saved payment methods yet.';
94
+ @Input() showAdd = true;
95
+
96
+ /** Emitted when the "Add" button is tapped. Consumer routes to its SetupIntent flow. */
97
+ @Output() addClicked = new EventEmitter<void>();
98
+ /** Emitted after a successful set-default flip. */
99
+ @Output() defaultChanged = new EventEmitter<UserPaymentMethod>();
100
+ /** Emitted after a successful delete. */
101
+ @Output() deleted = new EventEmitter<UserPaymentMethod>();
102
+
103
+ private readonly service = inject(UserPaymentMethodService);
104
+
105
+ readonly methods = signal<UserPaymentMethod[]>([]);
106
+ readonly errorMsg = signal<string>('');
107
+
108
+ ngOnInit(): void {
109
+ this.load();
110
+ }
111
+
112
+ load(): void {
113
+ this.run(
114
+ this.service.list(),
115
+ (rows) => {
116
+ // Sort default-first locally — keel's generic CRUD doesn't
117
+ // honour a sort hint without rest_api_header config.
118
+ const sorted = [...(rows ?? [])].sort((a, b) =>
119
+ Number(b.IsDefault) - Number(a.IsDefault),
120
+ );
121
+ this.methods.set(sorted);
122
+ },
123
+ 'Failed to load payment methods.',
124
+ );
125
+ }
126
+
127
+ setDefault(m: UserPaymentMethod): void {
128
+ this.service.setDefault(m.Id).subscribe({
129
+ next: () => {
130
+ this.methods.update((list) =>
131
+ list.map((row) => ({ ...row, IsDefault: row.Id === m.Id })),
132
+ );
133
+ this.defaultChanged.emit(m);
134
+ },
135
+ error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to set default'),
136
+ });
137
+ }
138
+
139
+ remove(m: UserPaymentMethod): void {
140
+ this.service.delete(m.Id).subscribe({
141
+ next: () => {
142
+ this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
143
+ this.deleted.emit(m);
144
+ },
145
+ error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to delete'),
146
+ });
147
+ }
148
+
149
+ iconFor(m: UserPaymentMethod): string {
150
+ switch (m.MethodType) {
151
+ case 'card': return 'credit_card';
152
+ case 'bank': return 'account_balance';
153
+ case 'apple_pay':
154
+ case 'google_pay':
155
+ case 'wallet': return 'wallet';
156
+ default: return 'credit_card';
157
+ }
158
+ }
159
+ }
package/src/index.ts CHANGED
@@ -34,9 +34,11 @@ export * from './component/billing/checkout_button';
34
34
  export * from './component/billing/payment_methods';
35
35
  export * from './component/payout/provider_onboarding';
36
36
  export * from './component/payout/bank_info_form';
37
+ export * from './component/payment/user_payment_methods';
37
38
  export * from './service/auth.service';
38
39
  export * from './service/billing.service';
39
40
  export * from './service/payout.service';
41
+ export * from './service/user_payment_method.service';
40
42
  export * from './service/script_loader';
41
43
  export * from './service/auth.interceptor';
42
44
  export * from './service/api-response.interceptor';
@@ -328,3 +328,27 @@ export interface CountryProfile {
328
328
  taxIdPlaceholder: string;
329
329
  taxIdHint: string;
330
330
  }
331
+
332
+ // ── User payment methods ──
333
+ // Consumer-side saved cards/wallets. Mirrors basis user_payment_method
334
+ // one-to-one. Distinct from PaymentMethod (partner-scoped SaaS billing
335
+ // card).
336
+ //
337
+ // PascalCase fields match keel's generic REST CRUD response shape —
338
+ // list and delete go through that path (the table is UserSpecific so
339
+ // the auto-filter scopes reads + owner-locks DELETE). Only set-default
340
+ // has a custom endpoint because it needs atomic multi-row UPDATE.
341
+
342
+ export interface UserPaymentMethod {
343
+ Id: number;
344
+ UserId?: number;
345
+ MethodType: string; // matches basis constant_header 'payment_method_type' — card / bank / wallet / apple_pay / google_pay
346
+ Provider: string; // 'stripe', ...
347
+ LastFour?: string;
348
+ Brand?: string;
349
+ ExpiryMonth?: number;
350
+ ExpiryYear?: number;
351
+ Currency: string;
352
+ IsDefault: boolean;
353
+ CreatedAt: string;
354
+ }
@@ -31,11 +31,15 @@ export const RestURL = {
31
31
  deleteAccountURL: '/api/user/account',
32
32
  pushRegisterURL: '/api/push/register',
33
33
  pushRevokeURL: '/api/push/revoke',
34
- // Payout — keel/payout endpoints (B5b + A3 interface).
34
+ // Payout — keel/payout endpoints.
35
35
  payoutOnboardStartURL: '/api/v1/payout/onboard/start',
36
36
  payoutReusableURL: '/api/v1/payout/reusable',
37
37
  payoutReusableLinkURL: '/api/v1/payout/reusable/link',
38
38
  payoutStatusURL: '/api/v1/payout/status',
39
+ // User payment methods — list/delete go through keel's generic REST
40
+ // CRUD against the UserSpecific basis table; only set-default has a
41
+ // custom endpoint (atomic multi-row UPDATE).
42
+ paymentMethodSetDefaultURL: '/api/v1/payment-methods/set-default',
39
43
  };
40
44
 
41
45
  /** Call this at app startup to set the backend host URL */
@@ -0,0 +1,48 @@
1
+ import { inject, Injectable } from '@angular/core';
2
+ import { HttpClient } from '@angular/common/http';
3
+ import { Observable } from 'rxjs';
4
+ import { RestURL } from './rest_url';
5
+ import { BackendService } from './rest_service';
6
+ import { UserPaymentMethod } from '../model/appdata';
7
+
8
+ /**
9
+ * UserPaymentMethodService — consumer-side saved-payment-methods API.
10
+ *
11
+ * list and delete go through keel's generic REST CRUD against the
12
+ * basis user_payment_method table — that table is UserSpecific (FK
13
+ * column `user_id`), so the auto-filter scopes reads + owner-locks
14
+ * DELETE without any custom server-side glue. setDefault has a custom
15
+ * endpoint because it needs an atomic multi-row UPDATE
16
+ * (`SET is_default = (id = ?) WHERE user_id = session`) that abstract
17
+ * single-row CRUD can't express.
18
+ */
19
+ @Injectable({ providedIn: 'root' })
20
+ export class UserPaymentMethodService {
21
+ private readonly http = inject(HttpClient);
22
+ private readonly backend = inject(BackendService);
23
+
24
+ /**
25
+ * List the caller's saved payment methods. Returns rows with PascalCase
26
+ * fields per the generic-CRUD convention; the default row sorts first
27
+ * server-side (filter ordering is set on rest_api_header).
28
+ */
29
+ list(): Observable<UserPaymentMethod[]> {
30
+ return this.backend.list<UserPaymentMethod>('user_payment_method');
31
+ }
32
+
33
+ /** Delete a saved payment method. user_id is auto-scoped by keel. */
34
+ delete(id: number): Observable<{ message: string }> {
35
+ return this.backend.delete('user_payment_method', { Id: String(id) });
36
+ }
37
+
38
+ /**
39
+ * Mark one row as default; clears every other row in the same call.
40
+ * Custom endpoint — see service-class doc for the rationale.
41
+ */
42
+ setDefault(id: number): Observable<{ message: string }> {
43
+ return this.http.post<{ message: string }>(
44
+ RestURL.httpHost + RestURL.paymentMethodSetDefaultURL,
45
+ { id },
46
+ );
47
+ }
48
+ }