@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.
- package/README.md +4 -2
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +1 -1
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +2 -2
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- 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
|
-
|
|
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
|
|
56
|
+
console.error(`backend returned error code ${error.status}`, error.error);
|
|
19
57
|
}
|
|
20
|
-
return throwError(() =>
|
|
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)
|
|
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
|
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
79
|
-
* Used to scope the response interceptor's envelope-unwrapping so
|
|
80
|
-
* responses
|
|
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 =
|
|
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) {
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
27
|
-
const
|
|
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
|
}
|