@nauticana/sail 1.1.9 → 1.1.11
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/sms_consent.html +11 -0
- package/src/component/login/sms_consent.ts +38 -0
- 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 +5 -1
- package/src/index.ts +2 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation,
|
|
2
|
+
ChangeDetectionStrategy, Component, DestroyRef, OnInit, ViewEncapsulation,
|
|
3
3
|
computed, effect, inject, input, output, signal,
|
|
4
4
|
} from '@angular/core';
|
|
5
5
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
@@ -28,7 +28,7 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
|
28
28
|
encapsulation: ViewEncapsulation.None,
|
|
29
29
|
imports: [ReactiveFormsModule, MatCheckboxModule],
|
|
30
30
|
})
|
|
31
|
-
export class ConsentGateComponent {
|
|
31
|
+
export class ConsentGateComponent implements OnInit {
|
|
32
32
|
private readonly destroyRef = inject(DestroyRef);
|
|
33
33
|
protected readonly guiConfig: SailGuiConfig =
|
|
34
34
|
inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
|
|
@@ -74,7 +74,9 @@ export class ConsentGateComponent {
|
|
|
74
74
|
}));
|
|
75
75
|
|
|
76
76
|
constructor() {
|
|
77
|
-
// Register optional consent controls
|
|
77
|
+
// Register optional consent controls when the input changes. add/removeControl
|
|
78
|
+
// fire valueChanges, so the subscriptions below emit — never emit from an
|
|
79
|
+
// effect: its microtask scheduling is unreliable for outputs in AOT builds.
|
|
78
80
|
effect(() => {
|
|
79
81
|
const options = this.optionalConsents();
|
|
80
82
|
for (const opt of options) {
|
|
@@ -85,33 +87,24 @@ export class ConsentGateComponent {
|
|
|
85
87
|
);
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
|
-
// Drop controls for options that were removed.
|
|
89
90
|
for (const key of Object.keys(this.form.controls)) {
|
|
90
91
|
if (key === ConsentType.PRIVACY_POLICY || key === ConsentType.CROSS_BORDER) continue;
|
|
91
92
|
if (!options.some((o) => o.id === key)) this.form.removeControl(key);
|
|
92
93
|
}
|
|
93
|
-
this.formValue.set(this.form.getRawValue());
|
|
94
|
-
this.formValid.set(this.form.valid);
|
|
95
|
-
this.consentStateChange.emit(this.state());
|
|
96
94
|
});
|
|
97
95
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
111
|
-
this.form.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
|
112
|
-
this.formValid.set(this.form.valid);
|
|
113
|
-
this.consentStateChange.emit(this.state());
|
|
114
|
-
});
|
|
96
|
+
this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.syncAndEmit());
|
|
97
|
+
this.form.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.syncAndEmit());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ngOnInit() {
|
|
101
|
+
this.syncAndEmit();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private syncAndEmit() {
|
|
105
|
+
this.formValue.set(this.form.getRawValue());
|
|
106
|
+
this.formValid.set(this.form.valid);
|
|
107
|
+
this.consentStateChange.emit(this.state());
|
|
115
108
|
}
|
|
116
109
|
|
|
117
110
|
protected readonly ConsentType = ConsentType;
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
<form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
|
|
16
16
|
<mat-form-field appearance="outline">
|
|
17
17
|
<mat-label>Username</mat-label>
|
|
18
|
-
<input matInput type="text" formControlName="username" required>
|
|
18
|
+
<input matInput type="text" formControlName="username" autocomplete="username" required>
|
|
19
19
|
</mat-form-field>
|
|
20
20
|
<mat-form-field appearance="outline">
|
|
21
21
|
<mat-label>Password</mat-label>
|
|
22
|
-
<input matInput type="password" formControlName="password" required>
|
|
22
|
+
<input matInput type="password" formControlName="password" autocomplete="current-password" required>
|
|
23
23
|
</mat-form-field>
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
@if (errorMessage()) {
|
|
25
|
+
<p class="auth-error" role="alert">{{ errorMessage() }}</p>
|
|
26
|
+
}
|
|
27
|
+
<button matButton="elevated" class="primary" type="submit" [disabled]="!loginForm.valid || loading()">
|
|
28
|
+
{{ loading() ? 'Signing in…' : 'Login' }}
|
|
26
29
|
</button>
|
|
27
30
|
</form>
|
|
28
31
|
@if (showSocialLogin()) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core';
|
|
2
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
2
3
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
4
|
import { BaseAuthService } from '../../service/auth.service';
|
|
4
5
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
@@ -23,7 +24,7 @@ import { SocialLoginComponent } from './social_login';
|
|
|
23
24
|
SocialLoginComponent,
|
|
24
25
|
],
|
|
25
26
|
})
|
|
26
|
-
export class LoginComponent {
|
|
27
|
+
export class LoginComponent extends BaseAsync {
|
|
27
28
|
private auth = inject(BaseAuthService);
|
|
28
29
|
protected fb = inject(FormBuilder);
|
|
29
30
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
@@ -52,10 +53,13 @@ export class LoginComponent {
|
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
login(): void {
|
|
55
|
-
if (this.loginForm.valid)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
if (!this.loginForm.valid) return;
|
|
57
|
+
const { username, password } = this.loginForm.value;
|
|
58
|
+
this.run(
|
|
59
|
+
this.auth.login(username!, password!),
|
|
60
|
+
() => {},
|
|
61
|
+
'Invalid username or password.',
|
|
62
|
+
);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
/**
|
|
@@ -4,17 +4,25 @@
|
|
|
4
4
|
<p class="otp-contact">{{ contact() }}</p>
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
<
|
|
7
|
+
<input class="otp-input-field"
|
|
8
|
+
type="text"
|
|
9
|
+
inputmode="numeric"
|
|
10
|
+
autocomplete="one-time-code"
|
|
11
|
+
[attr.maxlength]="length()"
|
|
12
|
+
[value]="code()"
|
|
13
|
+
(input)="onCodeInput($event)"
|
|
14
|
+
[disabled]="disabled()"
|
|
15
|
+
aria-label="Verification code">
|
|
16
|
+
|
|
17
|
+
<div class="otp-digits" aria-hidden="true">
|
|
8
18
|
@for (digit of digits(); track $index) {
|
|
9
|
-
<div class="otp-digit"
|
|
10
|
-
[class.active]="$index === currentIndex()"
|
|
11
|
-
[attr.aria-label]="'Digit ' + ($index + 1)">
|
|
19
|
+
<div class="otp-digit" [class.active]="$index === currentIndex()">
|
|
12
20
|
{{ digit }}
|
|
13
21
|
</div>
|
|
14
22
|
}
|
|
15
23
|
</div>
|
|
16
24
|
|
|
17
|
-
<p class="otp-resend">
|
|
25
|
+
<p class="otp-resend" aria-live="polite">
|
|
18
26
|
@if (canResend()) {
|
|
19
27
|
<button matButton class="resend-btn" type="button" (click)="onResend()">Resend code</button>
|
|
20
28
|
} @else {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ChangeDetectionStrategy, Component, DestroyRef,
|
|
2
|
+
ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation,
|
|
3
3
|
computed, effect, inject, input, output, signal,
|
|
4
4
|
} from '@angular/core';
|
|
5
5
|
import { MatButtonModule } from '@angular/material/button';
|
|
6
6
|
import { MatIconModule } from '@angular/material/icon';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* OtpInput —
|
|
9
|
+
* OtpInput — numeric OTP entry with resend countdown. A real (styleable)
|
|
10
|
+
* input drives the digit boxes, so typing, paste, and one-time-code
|
|
11
|
+
* autofill all work; the keypad is a touch-friendly supplement.
|
|
10
12
|
*
|
|
11
13
|
* Owns no service layer. The parent component sends the OTP (via
|
|
12
14
|
* BaseAuthService.sendOtp) and subscribes to `codeComplete` / `resend` to
|
|
@@ -19,17 +21,14 @@ import { MatIconModule } from '@angular/material/icon';
|
|
|
19
21
|
encapsulation: ViewEncapsulation.None,
|
|
20
22
|
imports: [MatButtonModule, MatIconModule],
|
|
21
23
|
})
|
|
22
|
-
export class OtpInputComponent
|
|
24
|
+
export class OtpInputComponent {
|
|
23
25
|
private readonly destroyRef = inject(DestroyRef);
|
|
24
26
|
|
|
25
27
|
readonly length = input(6);
|
|
26
28
|
readonly contact = input('');
|
|
27
29
|
// Fallback only. Consumers should pass the value returned by
|
|
28
|
-
// /public/otp/send in `resendCountdownSec`
|
|
29
|
-
//
|
|
30
|
-
// --otp_token_ttl_seconds flag and matches the lifetime of the OTP
|
|
31
|
-
// code itself). 300s is the legacy default, kept so existing callers
|
|
32
|
-
// without the new field don't break.
|
|
30
|
+
// /public/otp/send in `resendCountdownSec` — the authoritative TTL from
|
|
31
|
+
// keel's --otp_token_ttl_seconds. The countdown restarts when it changes.
|
|
33
32
|
readonly resendCountdownSec = input(300);
|
|
34
33
|
readonly disabled = input(false);
|
|
35
34
|
|
|
@@ -48,48 +47,56 @@ export class OtpInputComponent implements OnInit {
|
|
|
48
47
|
return idx === -1 ? d.length - 1 : idx;
|
|
49
48
|
});
|
|
50
49
|
|
|
50
|
+
readonly code = computed(() => this.digits().join(''));
|
|
51
|
+
|
|
51
52
|
readonly keypadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'] as const;
|
|
52
53
|
|
|
53
54
|
constructor() {
|
|
54
|
-
// Reset the digits array whenever `length` input changes.
|
|
55
55
|
effect(() => { this.digits.set(Array(this.length()).fill('')); });
|
|
56
|
+
// Restarts when the parent binds the authoritative TTL (arrives async).
|
|
57
|
+
effect(() => {
|
|
58
|
+
const ttl = this.resendCountdownSec();
|
|
59
|
+
this.startCountdown(ttl);
|
|
60
|
+
});
|
|
56
61
|
this.destroyRef.onDestroy(() => this.clearTimer());
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
this.
|
|
64
|
+
onCodeInput(event: Event) {
|
|
65
|
+
if (this.disabled()) return;
|
|
66
|
+
const target = event.target as HTMLInputElement;
|
|
67
|
+
const raw = target.value.replace(/\D/g, '').slice(0, this.length());
|
|
68
|
+
target.value = raw;
|
|
69
|
+
this.setCode(raw);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private setCode(raw: string) {
|
|
73
|
+
const d = Array<string>(this.length()).fill('');
|
|
74
|
+
for (let i = 0; i < raw.length; i++) d[i] = raw[i];
|
|
75
|
+
this.digits.set(d);
|
|
76
|
+
if (raw.length === this.length()) this.codeComplete.emit(raw);
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
onKeyPress(key: string) {
|
|
64
80
|
if (this.disabled()) return;
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
d[idx] = key;
|
|
69
|
-
this.digits.set(d);
|
|
70
|
-
if (idx === this.length() - 1) this.codeComplete.emit(d.join(''));
|
|
81
|
+
const current = this.code();
|
|
82
|
+
if (current.length >= this.length()) return;
|
|
83
|
+
this.setCode(current + key);
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
onBackspace() {
|
|
74
87
|
if (this.disabled()) return;
|
|
75
|
-
|
|
76
|
-
let idx = d.findIndex((v) => v === '');
|
|
77
|
-
if (idx === -1) idx = d.length;
|
|
78
|
-
if (idx > 0) {
|
|
79
|
-
d[idx - 1] = '';
|
|
80
|
-
this.digits.set(d);
|
|
81
|
-
}
|
|
88
|
+
this.setCode(this.code().slice(0, -1));
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
onResend() {
|
|
85
92
|
this.resend.emit();
|
|
86
93
|
this.digits.set(Array(this.length()).fill(''));
|
|
87
|
-
this.startCountdown();
|
|
94
|
+
this.startCountdown(this.resendCountdownSec());
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
private startCountdown() {
|
|
97
|
+
private startCountdown(seconds: number) {
|
|
91
98
|
this.clearTimer();
|
|
92
|
-
this.countdown.set(
|
|
99
|
+
this.countdown.set(seconds);
|
|
93
100
|
this.canResend.set(false);
|
|
94
101
|
this.countdownTimer = setInterval(() => {
|
|
95
102
|
const next = this.countdown() - 1;
|
|
@@ -13,34 +13,51 @@
|
|
|
13
13
|
<div class="form-row">
|
|
14
14
|
<mat-form-field appearance="outline">
|
|
15
15
|
<mat-label>First Name</mat-label>
|
|
16
|
-
<input matInput type="text" formControlName="FirstName" required>
|
|
16
|
+
<input matInput type="text" formControlName="FirstName" autocomplete="given-name" required>
|
|
17
17
|
</mat-form-field>
|
|
18
18
|
|
|
19
19
|
<mat-form-field appearance="outline">
|
|
20
20
|
<mat-label>Last Name</mat-label>
|
|
21
|
-
<input matInput type="text" formControlName="LastName" required>
|
|
21
|
+
<input matInput type="text" formControlName="LastName" autocomplete="family-name" required>
|
|
22
22
|
</mat-form-field>
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
25
|
<mat-form-field appearance="outline">
|
|
26
26
|
<mat-label>Email</mat-label>
|
|
27
|
-
<input matInput type="email" formControlName="Email" required>
|
|
27
|
+
<input matInput type="email" formControlName="Email" autocomplete="email" required>
|
|
28
|
+
@if (registerForm.controls.Email.hasError('email')) {
|
|
29
|
+
<mat-error>Enter a valid email address.</mat-error>
|
|
30
|
+
}
|
|
31
|
+
</mat-form-field>
|
|
32
|
+
|
|
33
|
+
<mat-form-field appearance="outline">
|
|
34
|
+
<mat-label>Username</mat-label>
|
|
35
|
+
<input matInput type="text" formControlName="UserName" autocomplete="username" required>
|
|
28
36
|
</mat-form-field>
|
|
29
37
|
|
|
30
38
|
<div class="form-row">
|
|
31
39
|
<mat-form-field appearance="outline">
|
|
32
|
-
<mat-label>
|
|
33
|
-
<input matInput type="
|
|
40
|
+
<mat-label>Password</mat-label>
|
|
41
|
+
<input matInput type="password" formControlName="Password" autocomplete="new-password" required>
|
|
42
|
+
@if (registerForm.controls.Password.hasError('minlength')) {
|
|
43
|
+
<mat-error>At least 6 characters.</mat-error>
|
|
44
|
+
}
|
|
34
45
|
</mat-form-field>
|
|
35
46
|
|
|
36
47
|
<mat-form-field appearance="outline">
|
|
37
|
-
<mat-label>Password</mat-label>
|
|
38
|
-
<input matInput type="password" formControlName="
|
|
48
|
+
<mat-label>Confirm Password</mat-label>
|
|
49
|
+
<input matInput type="password" formControlName="ConfirmPassword" autocomplete="new-password" required>
|
|
39
50
|
</mat-form-field>
|
|
40
51
|
</div>
|
|
52
|
+
@if (registerForm.hasError('passwordMismatch') && registerForm.controls.ConfirmPassword.touched) {
|
|
53
|
+
<div class="geocode-status geocode-error" role="alert">Passwords do not match.</div>
|
|
54
|
+
}
|
|
41
55
|
|
|
42
56
|
<div class="auth-section-label">Plan</div>
|
|
43
57
|
|
|
58
|
+
@if (plansError()) {
|
|
59
|
+
<div class="geocode-status geocode-error" role="alert">{{ plansError() }}</div>
|
|
60
|
+
}
|
|
44
61
|
<mat-form-field appearance="outline">
|
|
45
62
|
<mat-label>Subscription plan</mat-label>
|
|
46
63
|
<mat-select formControlName="PlanID" required>
|
|
@@ -62,12 +79,12 @@
|
|
|
62
79
|
<div class="form-row">
|
|
63
80
|
<mat-form-field appearance="outline">
|
|
64
81
|
<mat-label>Phone</mat-label>
|
|
65
|
-
<input matInput type="tel" formControlName="Phone" required>
|
|
82
|
+
<input matInput type="tel" formControlName="Phone" autocomplete="tel" required>
|
|
66
83
|
</mat-form-field>
|
|
67
84
|
|
|
68
85
|
<mat-form-field appearance="outline">
|
|
69
86
|
<mat-label>Website URL</mat-label>
|
|
70
|
-
<input matInput type="url" formControlName="DomainURL" required>
|
|
87
|
+
<input matInput type="url" formControlName="DomainURL" autocomplete="url" required>
|
|
71
88
|
</mat-form-field>
|
|
72
89
|
</div>
|
|
73
90
|
|
|
@@ -75,30 +92,30 @@
|
|
|
75
92
|
|
|
76
93
|
<mat-form-field appearance="outline">
|
|
77
94
|
<mat-label>Address (Street and Number)</mat-label>
|
|
78
|
-
<input matInput type="text" formControlName="Address" required>
|
|
95
|
+
<input matInput type="text" formControlName="Address" autocomplete="address-line1" required>
|
|
79
96
|
</mat-form-field>
|
|
80
97
|
|
|
81
98
|
<div class="form-row">
|
|
82
99
|
<mat-form-field appearance="outline">
|
|
83
100
|
<mat-label>City</mat-label>
|
|
84
|
-
<input matInput type="text" formControlName="City" required>
|
|
101
|
+
<input matInput type="text" formControlName="City" autocomplete="address-level2" required>
|
|
85
102
|
</mat-form-field>
|
|
86
103
|
|
|
87
104
|
<mat-form-field appearance="outline">
|
|
88
105
|
<mat-label>State</mat-label>
|
|
89
|
-
<input matInput type="text" formControlName="State" required>
|
|
106
|
+
<input matInput type="text" formControlName="State" autocomplete="address-level1" required>
|
|
90
107
|
</mat-form-field>
|
|
91
108
|
</div>
|
|
92
109
|
|
|
93
110
|
<div class="form-row">
|
|
94
111
|
<mat-form-field appearance="outline">
|
|
95
112
|
<mat-label>Zipcode</mat-label>
|
|
96
|
-
<input matInput type="text" formControlName="Zipcode" required>
|
|
113
|
+
<input matInput type="text" formControlName="Zipcode" autocomplete="postal-code" required>
|
|
97
114
|
</mat-form-field>
|
|
98
115
|
|
|
99
116
|
<mat-form-field appearance="outline">
|
|
100
117
|
<mat-label>Country</mat-label>
|
|
101
|
-
<input matInput type="text" formControlName="Country" required>
|
|
118
|
+
<input matInput type="text" formControlName="Country" autocomplete="country-name" required>
|
|
102
119
|
</mat-form-field>
|
|
103
120
|
</div>
|
|
104
121
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, inject,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, DestroyRef, signal, ViewEncapsulation, OnInit } from '@angular/core';
|
|
2
2
|
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
3
|
-
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
|
|
4
4
|
import { Router, RouterLink } from '@angular/router';
|
|
5
5
|
import { BaseAuthService } from '../../service/auth.service';
|
|
6
6
|
import { BillingService } from '../../service/billing.service';
|
|
@@ -9,11 +9,20 @@ import { MatFormFieldModule } from "@angular/material/form-field";
|
|
|
9
9
|
import { MatInputModule } from '@angular/material/input';
|
|
10
10
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
11
11
|
import { MatSelectModule } from '@angular/material/select';
|
|
12
|
-
import {
|
|
12
|
+
import { catchError, debounceTime, map, merge, of, switchMap, tap } from 'rxjs';
|
|
13
13
|
import { HttpClient } from '@angular/common/http';
|
|
14
14
|
import { PartnerRegistration, PublicPlan } from '../../model/appdata';
|
|
15
15
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
16
16
|
import { fromPriceLabel } from '../../util/money';
|
|
17
|
+
import { errorDetail } from '../../util/errors';
|
|
18
|
+
|
|
19
|
+
const passwordsMatch = (group: AbstractControl): ValidationErrors | null =>
|
|
20
|
+
group.get('Password')?.value === group.get('ConfirmPassword')?.value ? null : { passwordMismatch: true };
|
|
21
|
+
|
|
22
|
+
interface GeocodingResponse {
|
|
23
|
+
results: { geometry: { location: { lat: number; lng: number } } }[];
|
|
24
|
+
status: string;
|
|
25
|
+
}
|
|
17
26
|
|
|
18
27
|
@Component({
|
|
19
28
|
selector: 'sail-register',
|
|
@@ -43,14 +52,16 @@ export class RegisterComponent implements OnInit {
|
|
|
43
52
|
protected readonly geocodeEnabled = !!this.guiConfig.googleMapsApiKey;
|
|
44
53
|
|
|
45
54
|
readonly registerError = signal('');
|
|
55
|
+
readonly plansError = signal('');
|
|
46
56
|
readonly plans = signal<PublicPlan[]>([]);
|
|
47
57
|
|
|
48
58
|
readonly registerForm = this.fb.group({
|
|
49
59
|
FirstName: ['', Validators.required],
|
|
50
60
|
LastName: ['', Validators.required],
|
|
51
61
|
UserName: ['', Validators.required],
|
|
52
|
-
Email: ['', Validators.required],
|
|
53
|
-
Password: ['', Validators.required],
|
|
62
|
+
Email: ['', [Validators.required, Validators.email]],
|
|
63
|
+
Password: ['', [Validators.required, Validators.minLength(6)]],
|
|
64
|
+
ConfirmPassword: ['', Validators.required],
|
|
54
65
|
PartnerCaption: ['', Validators.required],
|
|
55
66
|
Address: ['', Validators.required],
|
|
56
67
|
City: ['', Validators.required],
|
|
@@ -63,7 +74,7 @@ export class RegisterComponent implements OnInit {
|
|
|
63
74
|
Latitude: [null as number | null],
|
|
64
75
|
Longitude: [null as number | null],
|
|
65
76
|
consentLocation: [false, this.geocodeEnabled ? Validators.requiredTrue : []],
|
|
66
|
-
});
|
|
77
|
+
}, { validators: passwordsMatch });
|
|
67
78
|
|
|
68
79
|
readonly geocodeStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
69
80
|
readonly geocodeError = signal('');
|
|
@@ -89,106 +100,86 @@ export class RegisterComponent implements OnInit {
|
|
|
89
100
|
ngOnInit() {
|
|
90
101
|
this.billing.listPlans().subscribe({
|
|
91
102
|
next: (list) => this.plans.set(list ?? []),
|
|
92
|
-
error: () => this.
|
|
103
|
+
error: () => this.plansError.set('Could not load subscription plans. Please reload the page.'),
|
|
93
104
|
});
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
constructor() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (this.geocodeEnabled && consent) {
|
|
100
|
-
this.tryGeocode();
|
|
101
|
-
} else {
|
|
102
|
-
this.registerForm.patchValue({ Latitude: null, Longitude: null });
|
|
103
|
-
this.geocodeStatus.set('idle');
|
|
104
|
-
this.geocodeError.set('');
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
+
// Debounced + switchMapped so per-keystroke edits don't fan out billable
|
|
109
|
+
// geocode calls and a stale response can't overwrite a newer address.
|
|
108
110
|
merge(
|
|
109
111
|
...this.addressFields.map((f) => this.registerForm.controls[f].valueChanges),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
});
|
|
112
|
+
this.registerForm.controls.consentLocation.valueChanges,
|
|
113
|
+
).pipe(
|
|
114
|
+
takeUntilDestroyed(this.destroyRef),
|
|
115
|
+
tap(() => this.registerForm.patchValue({ Latitude: null, Longitude: null })),
|
|
116
|
+
debounceTime(400),
|
|
117
|
+
switchMap(() => this.geocode$()),
|
|
118
|
+
).subscribe();
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
private
|
|
121
|
+
private geocode$() {
|
|
122
|
+
if (!this.geocodeEnabled || !this.consentLocation()) {
|
|
123
|
+
this.geocodeStatus.set('idle');
|
|
124
|
+
this.geocodeError.set('');
|
|
125
|
+
return of(null);
|
|
126
|
+
}
|
|
121
127
|
const { Address, City, State, Zipcode, Country } = this.registerForm.getRawValue();
|
|
122
128
|
if (!Address || !City || !State || !Zipcode || !Country) {
|
|
123
129
|
this.geocodeStatus.set('idle');
|
|
124
130
|
this.geocodeError.set('');
|
|
125
|
-
return;
|
|
131
|
+
return of(null);
|
|
126
132
|
}
|
|
127
133
|
this.geocodeStatus.set('loading');
|
|
128
134
|
this.geocodeError.set('');
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
const address = `${Address}, ${City}, ${State} ${Zipcode}, ${Country}`;
|
|
136
|
+
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${this.getGoogleMapsApiKey()}`;
|
|
137
|
+
return this.http.get<GeocodingResponse>(url).pipe(
|
|
138
|
+
map((data) => {
|
|
139
|
+
if (data.status !== 'OK' || data.results.length === 0) {
|
|
140
|
+
throw new Error(`Geocoding failed: ${data.status}`);
|
|
141
|
+
}
|
|
142
|
+
return data.results[0].geometry.location;
|
|
143
|
+
}),
|
|
144
|
+
tap((coords) => {
|
|
145
|
+
this.registerForm.patchValue({ Latitude: coords.lat, Longitude: coords.lng });
|
|
146
|
+
this.geocodeStatus.set('success');
|
|
147
|
+
}),
|
|
148
|
+
catchError(() => {
|
|
149
|
+
this.registerForm.patchValue({ Latitude: null, Longitude: null });
|
|
150
|
+
this.geocodeStatus.set('error');
|
|
151
|
+
this.geocodeError.set('Could not verify this address. Please check and try again.');
|
|
152
|
+
return of(null);
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
register() {
|
|
141
|
-
if (this.registerForm.valid)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private async getCoordinates(address: string): Promise<{ lat: number; lng: number }> {
|
|
170
|
-
const apiKey = this.getGoogleMapsApiKey();
|
|
171
|
-
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
|
|
172
|
-
|
|
173
|
-
interface GeocodingResponse {
|
|
174
|
-
results: { geometry: { location: { lat: number; lng: number } } }[];
|
|
175
|
-
status: string;
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
const response = await firstValueFrom(
|
|
179
|
-
this.http.get<GeocodingResponse>(url).pipe(
|
|
180
|
-
map((data) => {
|
|
181
|
-
if (data.status === 'OK' && data.results.length > 0) {
|
|
182
|
-
return data.results[0].geometry.location;
|
|
183
|
-
} else {
|
|
184
|
-
throw new Error(`Geocoding failed: ${data.status}`);
|
|
185
|
-
}
|
|
186
|
-
}),
|
|
187
|
-
),
|
|
188
|
-
);
|
|
189
|
-
return response;
|
|
190
|
-
} catch (error) {
|
|
191
|
-
throw new Error(`Geocoding failed: ${error}`);
|
|
192
|
-
}
|
|
158
|
+
if (!this.registerForm.valid) return;
|
|
159
|
+
this.registerError.set('');
|
|
160
|
+
const formValue = this.registerForm.getRawValue();
|
|
161
|
+
const partnerReg: PartnerRegistration = {
|
|
162
|
+
FirstName: formValue.FirstName!,
|
|
163
|
+
LastName: formValue.LastName!,
|
|
164
|
+
UserName: formValue.UserName!,
|
|
165
|
+
Email: formValue.Email!,
|
|
166
|
+
Password: formValue.Password!,
|
|
167
|
+
PartnerCaption: formValue.PartnerCaption!,
|
|
168
|
+
Address: formValue.Address!,
|
|
169
|
+
City: formValue.City!,
|
|
170
|
+
State: formValue.State!,
|
|
171
|
+
Zipcode: formValue.Zipcode!,
|
|
172
|
+
Country: formValue.Country!,
|
|
173
|
+
Phone: formValue.Phone!,
|
|
174
|
+
DomainURL: formValue.DomainURL!,
|
|
175
|
+
Latitude: formValue.Latitude,
|
|
176
|
+
Longitude: formValue.Longitude,
|
|
177
|
+
PlanID: formValue.PlanID!,
|
|
178
|
+
};
|
|
179
|
+
this.auth.register(partnerReg).subscribe({
|
|
180
|
+
// Pass the email along so the confirm page pre-fills it.
|
|
181
|
+
next: () => this.router.navigate(['/confirm/register'], { queryParams: { email: formValue.Email } }),
|
|
182
|
+
error: (err) => this.registerError.set(errorDetail(err, 'Registration failed. Please try again.')),
|
|
183
|
+
});
|
|
193
184
|
}
|
|
194
185
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<p class="sail-sms-consent">
|
|
2
|
+
By providing your phone number and tapping “{{ action() }},” you agree to receive
|
|
3
|
+
recurring automated text messages ({{ messagePurpose() }}) from {{ brandName() }} at that number.
|
|
4
|
+
Message frequency varies. Msg & data rates may apply. Reply STOP to opt out, HELP for help.
|
|
5
|
+
@if (terms() || privacy()) {
|
|
6
|
+
See our
|
|
7
|
+
@if (terms()) { <a [href]="terms()" target="_blank" rel="noopener">Terms</a> }
|
|
8
|
+
@if (terms() && privacy()) { and }
|
|
9
|
+
@if (privacy()) { <a [href]="privacy()" target="_blank" rel="noopener">Privacy Policy</a> }.
|
|
10
|
+
}
|
|
11
|
+
</p>
|