@nauticana/sail 1.1.9 → 1.1.10

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 (77) hide show
  1. package/README.md +4 -2
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/social_login.html +2 -2
  35. package/src/component/login/social_login.ts +20 -13
  36. package/src/component/navigation/navigation.html +2 -2
  37. package/src/component/navigation/navigation.ts +2 -0
  38. package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
  39. package/src/component/payment/user_payment_methods.html +2 -2
  40. package/src/component/payment/user_payment_methods.ts +12 -23
  41. package/src/component/payout/bank_info_form.html +4 -4
  42. package/src/component/payout/bank_info_form.ts +22 -26
  43. package/src/component/payout/provider_onboarding.html +5 -5
  44. package/src/component/payout/provider_onboarding.ts +28 -48
  45. package/src/component/security/trusted_devices.html +1 -1
  46. package/src/component/security/trusted_devices.ts +0 -1
  47. package/src/component/security/twofactor_setup.html +8 -4
  48. package/src/component/security/twofactor_setup.ts +21 -3
  49. package/src/component/security/twofactor_verify.html +2 -1
  50. package/src/component/security/twofactor_verify.ts +2 -8
  51. package/src/component/table/table_detail.html +15 -14
  52. package/src/component/table/table_detail.ts +62 -41
  53. package/src/component/table/table_edit.html +15 -11
  54. package/src/component/table/table_edit.ts +94 -67
  55. package/src/component/table/table_list.html +45 -12
  56. package/src/component/table/table_list.ts +98 -26
  57. package/src/component/table/table_lookup.html +11 -8
  58. package/src/component/table/table_lookup.ts +45 -37
  59. package/src/component/table/table_report.html +2 -2
  60. package/src/component/table/table_report.ts +37 -31
  61. package/src/component/table/table_search.html +4 -4
  62. package/src/component/table/table_search.ts +36 -45
  63. package/src/config.ts +1 -1
  64. package/src/index.ts +1 -0
  65. package/src/model/appdata.ts +5 -9
  66. package/src/model/common.ts +27 -27
  67. package/src/model/decorator.ts +25 -21
  68. package/src/service/api-response.interceptor.ts +4 -1
  69. package/src/service/auth.interceptor.ts +27 -7
  70. package/src/service/auth.service.ts +188 -134
  71. package/src/service/label.service.ts +6 -5
  72. package/src/service/oauth_connection.service.ts +2 -2
  73. package/src/service/rest_service.ts +57 -13
  74. package/src/service/rest_url.ts +23 -7
  75. package/src/service/script_loader.ts +12 -2
  76. package/src/util/errors.ts +20 -0
  77. package/src/util/money.ts +19 -7
@@ -6,18 +6,56 @@ import { RestURL } from "./rest_url";
6
6
  import { BaseAuthService } from "./auth.service";
7
7
  import { BaseRestService } from "./base_rest.service";
8
8
 
9
+ /** keel's RFC 7807 error body (WriteError) — surfaced verbatim on SailApiError. */
10
+ export interface ProblemDetail {
11
+ title?: string;
12
+ detail?: string;
13
+ status?: number;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ /**
18
+ * Typed error thrown by BackendService CRUD calls. Preserves the HTTP status
19
+ * and keel's ProblemDetail so consumers can branch on status codes and show
20
+ * field-level validation messages instead of a generic string.
21
+ */
22
+ export class SailApiError extends Error {
23
+ readonly status: number;
24
+ readonly problem?: ProblemDetail;
25
+ readonly response: HttpErrorResponse;
26
+
27
+ constructor(response: HttpErrorResponse) {
28
+ const problem = (response.error && typeof response.error === 'object')
29
+ ? response.error as ProblemDetail
30
+ : undefined;
31
+ super(problem?.detail ?? problem?.title ?? response.message);
32
+ this.name = 'SailApiError';
33
+ this.status = response.status;
34
+ this.problem = problem;
35
+ this.response = response;
36
+ }
37
+ }
38
+
9
39
  @Injectable({providedIn: 'root'})
10
40
  export class BackendService extends BaseRestService {
11
41
  private readonly auth = inject(BaseAuthService);
12
- protected readonly prefix = this.url(RestURL.api_prefix);
13
42
 
14
- protected handleError(error: HttpErrorResponse) {
43
+ /** Resolved per call so configureRestUrls() may run any time before the first request. */
44
+ protected get prefix(): string {
45
+ return this.url(RestURL.api_prefix);
46
+ }
47
+
48
+ protected handleError(error: unknown): Observable<never> {
49
+ if (!(error instanceof HttpErrorResponse)) {
50
+ // e.g. crudUrl's missing-version Error — already descriptive, pass through.
51
+ return throwError(() => error);
52
+ }
15
53
  if (error.status === 0) {
16
54
  console.error('an error occurred', error.error);
17
55
  } else {
18
- console.error(`backend return error code: ${error.status} error body: ${error.error}`);
56
+ console.error(`backend returned error code ${error.status}`, error.error);
19
57
  }
20
- return throwError(() => 'error occurred, see console log and try again');
58
+ return throwError(() => new SailApiError(error));
21
59
  }
22
60
 
23
61
  /** Free-form path under the API base — for custom endpoints (e.g. `rides/start`). CRUD methods use `crudUrl` instead. */
@@ -41,22 +79,22 @@ export class BackendService extends BaseRestService {
41
79
  * domain-specific actions.
42
80
  */
43
81
  protected request<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: unknown): Observable<T> {
44
- return this.http.request<T>(method, this.apiUrl(path), { body }).pipe(catchError(this.handleError));
82
+ return this.http.request<T>(method, this.apiUrl(path), { body }).pipe(catchError((err) => this.handleError(err)));
45
83
  }
46
84
 
47
- /** Build query params from a flat string-string map; skips undefined/null. */
85
+ /** Build query params from a flat string-string map; skips undefined/null values. */
48
86
  private toParams(filter?: {[key: string]: string}): HttpParams {
49
87
  let params = new HttpParams();
50
88
  if (!filter) return params;
51
89
  for (const key in filter) {
52
- if (Object.prototype.hasOwnProperty.call(filter, key)) {
90
+ if (Object.prototype.hasOwnProperty.call(filter, key) && filter[key] != null) {
53
91
  params = params.set(key, filter[key]);
54
92
  }
55
93
  }
56
94
  return params;
57
95
  }
58
96
 
59
- /** Gates a CRUD call on appdata being loaded — `appData$` is a ReplaySubject(1) so this resolves synchronously once the cache is populated, and blocks the call (instead of throwing) when a component fires CRUD before loadAppData() finishes. */
97
+ /** Gates a CRUD call on appdata being loaded — `appData$` is a ReplaySubject(1) so this resolves synchronously once the cache is populated, and blocks the call (instead of throwing) while loadAppData() is in flight. A failed appdata load errors through here, so gated calls fail loudly instead of hanging. */
60
98
  private appDataReady(): Observable<ApplicationData> {
61
99
  return this.auth.getAppData().pipe(take(1));
62
100
  }
@@ -72,31 +110,37 @@ export class BackendService extends BaseRestService {
72
110
  this.crudUrl(apiName, 'list'),
73
111
  {params: this.toParams(filter)},
74
112
  )),
75
- catchError(this.handleError),
113
+ catchError((err) => this.handleError(err)),
76
114
  );
77
115
  }
78
116
 
79
117
  get<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T> {
80
118
  return this.appDataReady().pipe(
81
119
  switchMap(() => this.http.get<T>(this.crudUrl(apiName, 'get'), {params: this.toParams(filter)})),
82
- catchError(this.handleError),
120
+ catchError((err) => this.handleError(err)),
83
121
  );
84
122
  }
85
123
 
86
124
  post<T>(apiName: string, items: T | T[]) : Observable<{message: string}> {
87
125
  return this.appDataReady().pipe(
88
126
  switchMap(() => this.http.post<{message: string}>(this.crudUrl(apiName, 'post'), items)),
89
- catchError(this.handleError),
127
+ catchError((err) => this.handleError(err)),
90
128
  );
91
129
  }
92
130
 
93
131
  delete(apiName: string, filter?: {[key: string]: string}): Observable<{message: string}> {
94
132
  return this.appDataReady().pipe(
95
133
  switchMap(() => this.http.delete<{message: string}>(this.crudUrl(apiName, 'delete'), {params: this.toParams(filter)})),
96
- catchError(this.handleError),
134
+ catchError((err) => this.handleError(err)),
97
135
  );
98
136
  }
99
137
 
138
+ /** GET a report endpoint (`{host}{api_prefix}{apiPath}`) — rows for TableReport. */
139
+ report<T = Record<string, unknown>>(apiPath: string, params?: {[key: string]: string}): Observable<T[]> {
140
+ return this.http.get<T[]>(this.apiUrl(apiPath), { params: this.toParams(params) })
141
+ .pipe(catchError((err) => this.handleError(err)));
142
+ }
143
+
100
144
  /**
101
145
  * Fire a TableAction button. `actionPath` is the resolved URL path
102
146
  * the keel REST engine pre-computed onto TableAction.method
@@ -106,6 +150,6 @@ export class BackendService extends BaseRestService {
106
150
  * primary key values; for table-level actions it is typically `{}`.
107
151
  */
108
152
  executeAction<T = unknown>(actionPath: string, body: Record<string, unknown> = {}): Observable<T> {
109
- return this.http.post<T>(this.apiUrl(actionPath), body).pipe(catchError(this.handleError));
153
+ return this.http.post<T>(this.apiUrl(actionPath), body).pipe(catchError((err) => this.handleError(err)));
110
154
  }
111
155
  }
@@ -44,7 +44,11 @@ export const RestURL = {
44
44
  profilePhoneConfirmURL: '/api/user/profile/phone/confirm',
45
45
  pushRegisterURL: '/api/push/register',
46
46
  pushRevokeURL: '/api/push/revoke',
47
- // Payout — keel/payout endpoints.
47
+ // Payout — keel/payout endpoints. keel mounts PayoutHandler.Routes(prefix)
48
+ // under the app's REST prefix; these defaults assume the conventional
49
+ // '/api/v1'. Payout routes are not in the Apis dictionary, so the version
50
+ // cannot be derived at runtime — apps mounting elsewhere MUST override
51
+ // these via configureRestUrls.
48
52
  payoutOnboardStartURL: '/api/v1/payout/onboard/start',
49
53
  payoutReusableURL: '/api/v1/payout/reusable',
50
54
  payoutReusableLinkURL: '/api/v1/payout/reusable/link',
@@ -62,6 +66,17 @@ export function configureRestUrls(httpHost: string, overrides?: Partial<typeof R
62
66
  if (overrides) {
63
67
  Object.assign(RestURL, overrides);
64
68
  }
69
+ // A trailing slash would make url() produce host//api/... while the
70
+ // interceptor matchers look for host/api/... — normalize once here.
71
+ if (RestURL.httpHost.endsWith('/')) {
72
+ RestURL.httpHost = RestURL.httpHost.slice(0, -1);
73
+ }
74
+ }
75
+
76
+ /** Configured host without a trailing slash, so `host + '/api/'` never yields `//api/`. */
77
+ function normalizedHost(): string {
78
+ const host = RestURL.httpHost;
79
+ return host.endsWith('/') ? host.slice(0, -1) : host;
65
80
  }
66
81
 
67
82
  /**
@@ -70,17 +85,18 @@ export function configureRestUrls(httpHost: string, overrides?: Partial<typeof R
70
85
  * a third-party origin that merely happens to contain `/api/` in its path.
71
86
  */
72
87
  export function isKeelApiUrl(url: string): boolean {
73
- const host = RestURL.httpHost;
88
+ const host = normalizedHost();
74
89
  return host ? url.startsWith(host + '/api/') : url.startsWith('/api/');
75
90
  }
76
91
 
77
92
  /**
78
- * True when `url` targets the keel backend at all (`/api/` or `/public/`).
79
- * Used to scope the response interceptor's envelope-unwrapping so third-party
80
- * responses pass through untouched.
93
+ * True when `url` targets the keel backend's API surface (`/api/` or
94
+ * `/public/`). Used to scope the response interceptor's envelope-unwrapping so
95
+ * third-party responses — and non-API fetches from the backend host (assets,
96
+ * health checks) — pass through untouched.
81
97
  */
82
98
  export function isKeelUrl(url: string): boolean {
83
- const host = RestURL.httpHost;
84
- if (host) return url.startsWith(host + '/');
99
+ const host = normalizedHost();
100
+ if (host) return url.startsWith(host + '/api/') || url.startsWith(host + '/public/');
85
101
  return url.startsWith('/api/') || url.startsWith('/public/');
86
102
  }
@@ -15,17 +15,27 @@ export function loadScript(src: string): Promise<void> {
15
15
  return;
16
16
  }
17
17
  const prior = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
18
- if (prior) { resolve(); return; }
18
+ if (prior) {
19
+ // A tag we (or the host page) already inserted may still be loading —
20
+ // resolve on its load event, not on mere presence in the DOM.
21
+ if (prior.dataset['sailLoaded']) { resolve(); return; }
22
+ prior.addEventListener('load', () => resolve());
23
+ prior.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
24
+ return;
25
+ }
19
26
 
20
27
  const el = document.createElement('script');
21
28
  el.src = src;
22
29
  el.async = true;
23
30
  el.defer = true;
24
- el.onload = () => resolve();
31
+ el.onload = () => { el.dataset['sailLoaded'] = 'true'; resolve(); };
25
32
  el.onerror = () => reject(new Error(`Failed to load ${src}`));
26
33
  document.head.appendChild(el);
27
34
  });
28
35
 
29
36
  cache.set(src, p);
37
+ // Evict failures so a transient network error doesn't disable the feature
38
+ // (e.g. social login) for the rest of the page's lifetime.
39
+ p.catch(() => cache.delete(src));
30
40
  return p;
31
41
  }
@@ -0,0 +1,20 @@
1
+ import { HttpErrorResponse } from '@angular/common/http';
2
+ import { SailApiError } from '../service/rest_service';
3
+
4
+ /**
5
+ * Human-readable message from any sail error shape — SailApiError (BackendService),
6
+ * raw HttpErrorResponse (direct HttpClient calls), or plain Error.
7
+ */
8
+ export function errorDetail(err: unknown, fallback: string): string {
9
+ if (err instanceof SailApiError) {
10
+ return err.problem?.detail ?? err.problem?.title ?? err.message ?? fallback;
11
+ }
12
+ if (err instanceof HttpErrorResponse) {
13
+ const body = err.error as { detail?: string; message?: string } | null;
14
+ return body?.detail ?? body?.message ?? fallback;
15
+ }
16
+ if (err instanceof Error && err.message) {
17
+ return err.message;
18
+ }
19
+ return fallback;
20
+ }
package/src/util/money.ts CHANGED
@@ -13,17 +13,29 @@ export function formatCurrency(amount: number, currency: string): string {
13
13
  }
14
14
  }
15
15
 
16
+ /** PERIOD_TYPE code vocabulary — the single source for cycle labels/durations. */
17
+ export const PERIOD_TYPE_INFO: Record<string, { label: string; noun: string; suffix: string; months: number }> = {
18
+ D: { label: 'Daily', noun: 'day', suffix: '/day', months: 1 / 30 },
19
+ W: { label: 'Weekly', noun: 'week', suffix: '/wk', months: 7 / 30 },
20
+ M: { label: 'Monthly', noun: 'month', suffix: '/mo', months: 1 },
21
+ Q: { label: 'Quarterly', noun: 'quarter', suffix: '/qtr', months: 3 },
22
+ A: { label: 'Annual', noun: 'year', suffix: '/yr', months: 12 },
23
+ };
24
+
16
25
  /**
17
- * "From" price teaser for a plan card / dropdown — the lowest priced offer,
18
- * currency-formatted, prefixed "from " when the plan sells more than one priced
19
- * offer. Returns '' when the plan has no priced offers (free/trial), so callers
20
- * can omit the price line entirely. The exact offer is chosen later via
21
- * `<sail-price-selector>`.
26
+ * "From" price teaser for a plan card / dropdown — the cheapest offer once
27
+ * normalized per month (a $2 daily offer must not undercut a $30 monthly one),
28
+ * compared within one currency, rendered with its cycle ("from $30.00/mo").
29
+ * Returns '' when the plan has no priced offers (free/trial). The exact offer
30
+ * is chosen later via `<sail-price-selector>`.
22
31
  */
23
32
  export function fromPriceLabel(prices: PlanPrice[] | undefined): string {
24
33
  const priced = (prices ?? []).filter((p) => p.amount > 0);
25
34
  if (priced.length === 0) return '';
26
- const lowest = priced.reduce((a, b) => (b.amount < a.amount ? b : a));
27
- const formatted = formatCurrency(lowest.amount, lowest.currency);
35
+ const currency = priced[0].currency;
36
+ const comparable = priced.filter((p) => p.currency === currency);
37
+ const perMonth = (p: PlanPrice) => p.amount / (PERIOD_TYPE_INFO[p.billingCycle]?.months ?? 1);
38
+ const lowest = comparable.reduce((a, b) => (perMonth(b) < perMonth(a) ? b : a));
39
+ const formatted = formatCurrency(lowest.amount, lowest.currency) + (PERIOD_TYPE_INFO[lowest.billingCycle]?.suffix ?? '');
28
40
  return priced.length > 1 ? `from ${formatted}` : formatted;
29
41
  }