@7365admin1/layer-common 4.2.2-staging.264 → 4.2.2-staging.266
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/assets/css/shell.css +33 -0
- package/components/PublicOnboarding/ContractorRegistrationForm.vue +40 -5
- package/components/PublicOnboarding/VisitorQrCodeLinksCard.vue +137 -19
- package/composables/usePublicVisitorOnboarding.ts +14 -0
- package/package.json +1 -1
- package/utils/self-service-links.ts +45 -0
package/assets/css/shell.css
CHANGED
|
@@ -256,3 +256,36 @@
|
|
|
256
256
|
min-width: 44px;
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A toast must never take a click that was meant for the screen behind it.
|
|
262
|
+
*
|
|
263
|
+
* Every snackbar in the product is drawn at `z-index: 100000000` - the shared
|
|
264
|
+
* `Snackbar.vue` and both of `VisitorSocketPopUp.vue`'s - which is far above
|
|
265
|
+
* Vuetify's dialog stack (2000+). So a snackbar sits ON TOP of any dialog that
|
|
266
|
+
* is open, and its wrapper is a solid, `pointer-events: auto` box.
|
|
267
|
+
*
|
|
268
|
+
* Measured on staging, 1 Sep 2026: the realtime "New plate detected" toast
|
|
269
|
+
* landed over the Add Vehicle dialog's Submit button and swallowed the click,
|
|
270
|
+
* so on a busy gate - where a plate read arrives every few seconds - a vehicle
|
|
271
|
+
* could not be added at all. The tester could only finish the create by setting
|
|
272
|
+
* `pointer-events: none` on the snackbar by hand. It is not a vehicle problem:
|
|
273
|
+
* the same shared `Snackbar.vue` is mounted by 57 screens, so any toast that
|
|
274
|
+
* fires while any dialog is open can do this.
|
|
275
|
+
*
|
|
276
|
+
* The z-index is deliberately left alone - a camera-fault warning still has to
|
|
277
|
+
* be SEEN over a dialog. What changes is that only the toast's own controls
|
|
278
|
+
* take clicks; everything else passes through to whatever is underneath, which
|
|
279
|
+
* is exactly the behaviour the tester proved by hand.
|
|
280
|
+
*/
|
|
281
|
+
/* Two class names deep on purpose: Vuetify puts `v-overlay__content`, which
|
|
282
|
+
carries `pointer-events: auto`, on this same element. */
|
|
283
|
+
.v-snackbar .v-snackbar__wrapper {
|
|
284
|
+
pointer-events: none;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.v-snackbar .v-snackbar__wrapper .v-btn,
|
|
288
|
+
.v-snackbar .v-snackbar__wrapper a,
|
|
289
|
+
.v-snackbar .v-snackbar__wrapper [role="button"] {
|
|
290
|
+
pointer-events: auto;
|
|
291
|
+
}
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
label="Email"
|
|
47
47
|
density="comfortable"
|
|
48
48
|
variant="outlined"
|
|
49
|
-
|
|
49
|
+
:rules="[emailRule]"
|
|
50
50
|
/>
|
|
51
51
|
</v-col>
|
|
52
52
|
|
|
@@ -146,6 +146,15 @@
|
|
|
146
146
|
hide-details
|
|
147
147
|
class="mb-2"
|
|
148
148
|
/>
|
|
149
|
+
<v-text-field
|
|
150
|
+
v-model="member.email"
|
|
151
|
+
type="email"
|
|
152
|
+
label="Email"
|
|
153
|
+
density="comfortable"
|
|
154
|
+
variant="outlined"
|
|
155
|
+
:rules="[requiredRule, emailRule]"
|
|
156
|
+
class="mb-2"
|
|
157
|
+
/>
|
|
149
158
|
<InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details class="mb-2" />
|
|
150
159
|
<v-text-field
|
|
151
160
|
v-model="member.plateNumber"
|
|
@@ -156,6 +165,10 @@
|
|
|
156
165
|
/>
|
|
157
166
|
</div>
|
|
158
167
|
|
|
168
|
+
<v-alert v-if="membersError" type="error" variant="tonal" density="compact" class="mb-3">
|
|
169
|
+
{{ membersError }}
|
|
170
|
+
</v-alert>
|
|
171
|
+
|
|
159
172
|
<v-btn variant="outlined" block class="mb-3" prepend-icon="mdi-plus" @click="addDraftMember">
|
|
160
173
|
Add
|
|
161
174
|
</v-btn>
|
|
@@ -166,6 +179,8 @@
|
|
|
166
179
|
</template>
|
|
167
180
|
|
|
168
181
|
<script lang="ts" setup>
|
|
182
|
+
import { isLikelyEmail } from "../../utils/self-service-links";
|
|
183
|
+
|
|
169
184
|
const prop = defineProps({
|
|
170
185
|
site: { type: String, required: true },
|
|
171
186
|
});
|
|
@@ -176,6 +191,10 @@ const { createVisitor } = usePublicVisitorOnboarding();
|
|
|
176
191
|
const { isSupported: isContactPickerSupported, pickContact } = useContactPicker();
|
|
177
192
|
|
|
178
193
|
const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
|
|
194
|
+
// Optional, but if typed it has to be sendable - the API validates it with Joi
|
|
195
|
+
// and would otherwise reject the whole submission with a message about a field
|
|
196
|
+
// the visitor cannot see.
|
|
197
|
+
const emailRule = (v: any) => !v || isLikelyEmail(v) || "Enter a valid email address";
|
|
179
198
|
|
|
180
199
|
const contractorTypes = [
|
|
181
200
|
{ title: "Home Contractor", value: "home-contractor" },
|
|
@@ -199,25 +218,40 @@ const visitor = reactive({
|
|
|
199
218
|
remarks: "",
|
|
200
219
|
});
|
|
201
220
|
|
|
202
|
-
type TDraftMember = { name: string; nric: string; contact: string; plateNumber: string };
|
|
221
|
+
type TDraftMember = { name: string; nric: string; email: string; contact: string; plateNumber: string };
|
|
203
222
|
const members = ref<TDraftMember[]>([]);
|
|
204
223
|
const draftMembers = ref<TDraftMember[]>([]);
|
|
205
224
|
const showMembersDialog = ref(false);
|
|
225
|
+
const membersError = ref("");
|
|
206
226
|
|
|
207
227
|
watch(showMembersDialog, (open) => {
|
|
208
228
|
if (open) {
|
|
209
229
|
draftMembers.value = members.value.length
|
|
210
230
|
? members.value.map((m) => ({ ...m }))
|
|
211
|
-
: [{ name: "", nric: "", contact: "", plateNumber: "" }];
|
|
231
|
+
: [{ name: "", nric: "", email: "", contact: "", plateNumber: "" }];
|
|
232
|
+
membersError.value = "";
|
|
212
233
|
}
|
|
213
234
|
});
|
|
214
235
|
|
|
215
236
|
function addDraftMember() {
|
|
216
|
-
draftMembers.value.push({ name: "", nric: "", contact: "", plateNumber: "" });
|
|
237
|
+
draftMembers.value.push({ name: "", nric: "", email: "", contact: "", plateNumber: "" });
|
|
217
238
|
}
|
|
218
239
|
|
|
219
240
|
function saveMembers() {
|
|
220
|
-
members
|
|
241
|
+
// The members dialog is lazy (no `eager`), so its fields unmount when it
|
|
242
|
+
// closes and stop counting towards the form's own `formValid` - which is what
|
|
243
|
+
// gates Submit. Without this guard a malformed member address would sail past
|
|
244
|
+
// both, and the API would reject the entire submission with a Joi message
|
|
245
|
+
// naming a field the contractor can no longer see. Blank stays allowed: an
|
|
246
|
+
// email is optional per member, it just means no mail of their own.
|
|
247
|
+
const invalid = draftMembers.value.find((m) => m.email && !isLikelyEmail(m.email));
|
|
248
|
+
if (invalid) {
|
|
249
|
+
membersError.value = `Enter a valid email address for ${invalid.name || "this member"}, or leave it blank.`;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
membersError.value = "";
|
|
254
|
+
members.value = draftMembers.value.filter((m) => m.name && m.contact && m.email);
|
|
221
255
|
showMembersDialog.value = false;
|
|
222
256
|
}
|
|
223
257
|
|
|
@@ -256,6 +290,7 @@ async function submit() {
|
|
|
256
290
|
const res: any = await createVisitor(prop.site, payload);
|
|
257
291
|
emit("done", res?.status || "pending", res?._id, {
|
|
258
292
|
name: visitor.name,
|
|
293
|
+
email: visitor.email,
|
|
259
294
|
expectedCheckIn: visitor.expectedCheckIn,
|
|
260
295
|
members: res?.members || [],
|
|
261
296
|
});
|
|
@@ -14,30 +14,59 @@
|
|
|
14
14
|
Hi {{ leadName }}, here {{ members.length ? "are the check-in links for you and your team" : "is your check-in link" }}.
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
<
|
|
18
|
-
<p class="text-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
</v-btn>
|
|
23
|
-
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="leadLink" target="_blank">
|
|
24
|
-
View Link
|
|
25
|
-
</v-btn>
|
|
26
|
-
</div>
|
|
27
|
-
</div>
|
|
17
|
+
<template v-for="(row, index) in rows" :key="row._id">
|
|
18
|
+
<p v-if="index === 1" class="text-caption text-medium-emphasis mb-2">Members</p>
|
|
19
|
+
|
|
20
|
+
<div class="link-row pa-3 mb-3">
|
|
21
|
+
<p class="text-body-2 font-weight-medium mb-2">{{ row.name }}</p>
|
|
28
22
|
|
|
29
|
-
<template v-if="members.length">
|
|
30
|
-
<p class="text-caption text-medium-emphasis mb-2">Members</p>
|
|
31
|
-
<div v-for="m in members" :key="m._id" class="link-row pa-3 mb-3">
|
|
32
|
-
<p class="text-body-2 font-weight-medium mb-2">{{ m.name }}</p>
|
|
33
23
|
<div class="d-flex ga-2">
|
|
34
|
-
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(linkFor(
|
|
24
|
+
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(linkFor(row._id))">
|
|
35
25
|
Copy Link
|
|
36
26
|
</v-btn>
|
|
37
|
-
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="linkFor(
|
|
27
|
+
<v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="linkFor(row._id)" target="_blank">
|
|
38
28
|
View Link
|
|
39
29
|
</v-btn>
|
|
30
|
+
<v-btn
|
|
31
|
+
variant="outlined"
|
|
32
|
+
color="error"
|
|
33
|
+
size="small"
|
|
34
|
+
class="flex-grow-1"
|
|
35
|
+
:loading="resendState[row._id] === 'sending'"
|
|
36
|
+
:disabled="resendState[row._id] === 'sending'"
|
|
37
|
+
@click="onResend(row)"
|
|
38
|
+
>
|
|
39
|
+
{{ resendState[row._id] === "sent" ? "Sent" : "Resend" }}
|
|
40
|
+
</v-btn>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div v-if="showEmailInput[row._id]" class="d-flex ga-2 mt-3 align-start">
|
|
44
|
+
<v-text-field
|
|
45
|
+
v-model="emailDrafts[row._id]"
|
|
46
|
+
type="email"
|
|
47
|
+
label="Email"
|
|
48
|
+
density="compact"
|
|
49
|
+
variant="outlined"
|
|
50
|
+
hide-details
|
|
51
|
+
:disabled="resendState[row._id] === 'sending'"
|
|
52
|
+
@keyup.enter="onResend(row)"
|
|
53
|
+
/>
|
|
54
|
+
<v-btn
|
|
55
|
+
color="error"
|
|
56
|
+
variant="flat"
|
|
57
|
+
size="small"
|
|
58
|
+
class="mt-1"
|
|
59
|
+
:loading="resendState[row._id] === 'sending'"
|
|
60
|
+
:disabled="resendState[row._id] === 'sending' || !isLikelyEmail(emailDrafts[row._id])"
|
|
61
|
+
@click="onResend(row)"
|
|
62
|
+
>
|
|
63
|
+
Send
|
|
64
|
+
</v-btn>
|
|
40
65
|
</div>
|
|
66
|
+
|
|
67
|
+
<p v-if="resendError[row._id]" class="text-caption text-error mt-2 mb-0">
|
|
68
|
+
{{ resendError[row._id] }}
|
|
69
|
+
</p>
|
|
41
70
|
</div>
|
|
42
71
|
</template>
|
|
43
72
|
|
|
@@ -46,27 +75,116 @@
|
|
|
46
75
|
</v-btn>
|
|
47
76
|
|
|
48
77
|
<v-snackbar v-model="copied" timeout="1800" location="bottom">Link copied</v-snackbar>
|
|
78
|
+
<v-snackbar v-model="resent" timeout="2400" location="bottom">Email sent</v-snackbar>
|
|
49
79
|
|
|
50
80
|
<PublicOnboardingShareDialog v-model="showShare" :message="shareMessage" :link="leadLink" />
|
|
51
81
|
</v-card>
|
|
52
82
|
</template>
|
|
53
83
|
|
|
54
84
|
<script lang="ts" setup>
|
|
85
|
+
import { isLikelyEmail, needsEmailInput, passLink, type TPassRow } from "../../utils/self-service-links";
|
|
86
|
+
|
|
55
87
|
const props = defineProps<{
|
|
56
88
|
leadId: string;
|
|
57
89
|
leadName: string;
|
|
90
|
+
leadEmail?: string | null;
|
|
58
91
|
status: string;
|
|
59
92
|
expectedCheckIn?: string | null;
|
|
60
|
-
members: { _id: string; name: string }[];
|
|
93
|
+
members: { _id: string; name: string; email?: string | null }[];
|
|
61
94
|
}>();
|
|
62
95
|
|
|
96
|
+
const { resendSelfServicePass } = usePublicVisitorOnboarding();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Addresses typed into a row's inline field and accepted by the server. The
|
|
100
|
+
* props are a snapshot of the moment the group was created, so without this a
|
|
101
|
+
* row would keep asking for an address it has already been given.
|
|
102
|
+
*/
|
|
103
|
+
const sentEmails = ref<Record<string, string>>({});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The lead and every member as one list - they get the identical row, and the
|
|
107
|
+
* lead is simply the first of them. Index 1 is where the "Members" caption
|
|
108
|
+
* goes.
|
|
109
|
+
*/
|
|
110
|
+
const rows = computed<TPassRow[]>(() =>
|
|
111
|
+
[
|
|
112
|
+
{ _id: props.leadId, name: props.leadName, email: props.leadEmail ?? null },
|
|
113
|
+
...props.members,
|
|
114
|
+
].map((row) => ({ ...row, email: sentEmails.value[row._id] || row.email })),
|
|
115
|
+
);
|
|
116
|
+
|
|
63
117
|
function linkFor(id: string) {
|
|
64
118
|
const origin = import.meta.client ? window.location.origin : "";
|
|
65
|
-
return
|
|
119
|
+
return passLink(origin, id);
|
|
66
120
|
}
|
|
67
121
|
|
|
68
122
|
const leadLink = computed(() => linkFor(props.leadId));
|
|
69
123
|
|
|
124
|
+
const resendState = reactive<Record<string, "idle" | "sending" | "sent">>({});
|
|
125
|
+
const resendError = reactive<Record<string, string>>({});
|
|
126
|
+
const emailDrafts = reactive<Record<string, string>>({});
|
|
127
|
+
const showEmailInput = reactive<Record<string, boolean>>({});
|
|
128
|
+
const resent = ref(false);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One button, two jobs. A row that already has an address just sends. A row
|
|
132
|
+
* that has none opens its inline field first, and the second press - or Enter
|
|
133
|
+
* in the field - is the one that sends what was typed.
|
|
134
|
+
*/
|
|
135
|
+
async function onResend(row: TPassRow) {
|
|
136
|
+
// The inline Send button and the Enter key both land here, and neither is
|
|
137
|
+
// gone from the DOM while a send is in flight. Without this, a double press
|
|
138
|
+
// fires two requests for one row: the second reliably trips the server's own
|
|
139
|
+
// cooldown (it is stamped before the send), and if it settles last it
|
|
140
|
+
// overwrites a successful send with a cooldown error the visitor cannot act on.
|
|
141
|
+
if (resendState[row._id] === "sending") return;
|
|
142
|
+
|
|
143
|
+
const draft = String(emailDrafts[row._id] ?? "").trim();
|
|
144
|
+
|
|
145
|
+
if (needsEmailInput(row) && !isLikelyEmail(draft)) {
|
|
146
|
+
showEmailInput[row._id] = true;
|
|
147
|
+
resendError[row._id] = "";
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resendError[row._id] = "";
|
|
152
|
+
resendState[row._id] = "sending";
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Only sent for a row with no address of its own - the server refuses to
|
|
156
|
+
// redirect one that has, so there is nothing to gain by sending it anyway.
|
|
157
|
+
await resendSelfServicePass(row._id, needsEmailInput(row) ? draft : undefined);
|
|
158
|
+
resendState[row._id] = "sent";
|
|
159
|
+
// "Sent" is a moment-in-time confirmation, not a permanent state - the
|
|
160
|
+
// button still works on a later press, so it must not be stuck reading
|
|
161
|
+
// "Sent" for the rest of the page's life. Guarded: a later press may have
|
|
162
|
+
// already moved the row on to "sending" by the time this fires.
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
if (resendState[row._id] === "sent") resendState[row._id] = "idle";
|
|
165
|
+
}, 3000);
|
|
166
|
+
if (needsEmailInput(row)) {
|
|
167
|
+
sentEmails.value = { ...sentEmails.value, [row._id]: draft };
|
|
168
|
+
showEmailInput[row._id] = false;
|
|
169
|
+
}
|
|
170
|
+
resent.value = true;
|
|
171
|
+
} catch (error: any) {
|
|
172
|
+
resendState[row._id] = "idle";
|
|
173
|
+
const statusCode = error?.statusCode ?? error?.response?.status;
|
|
174
|
+
// The server's own words: the cooldown says how many seconds are left, and
|
|
175
|
+
// a rejected address says so. Both are written to be read by a visitor.
|
|
176
|
+
// The per-IP limiter's 429 is the one response here with no JSON body -
|
|
177
|
+
// it answers plain text, so `error.message` would otherwise put the raw
|
|
178
|
+
// "[POST] ... 429 Too Many Requests" fetch error on the row.
|
|
179
|
+
resendError[row._id] =
|
|
180
|
+
error?.data?.message ||
|
|
181
|
+
(statusCode === 429
|
|
182
|
+
? "Too many attempts. Please wait a moment and try again."
|
|
183
|
+
: error?.message) ||
|
|
184
|
+
"Unable to send that email. Please try again.";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
70
188
|
const arrivalDateDisplay = computed(() => {
|
|
71
189
|
if (!props.expectedCheckIn) return "";
|
|
72
190
|
const d = new Date(props.expectedCheckIn);
|
|
@@ -74,6 +74,19 @@ export default function usePublicVisitorOnboarding() {
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// The Resend button on the links card. `email` is only meaningful for a
|
|
78
|
+
// registration that carries no address of its own - the server refuses to
|
|
79
|
+
// redirect one that does, so sending it for an addressed row is harmless but
|
|
80
|
+
// pointless. A 429 (per-record cooldown, or the per-IP limiter) and a 400
|
|
81
|
+
// both arrive as a thrown error carrying the server's own message, which the
|
|
82
|
+
// card shows on the row.
|
|
83
|
+
function resendSelfServicePass(id: string, email?: string) {
|
|
84
|
+
return useNuxtApp().$api<{ message: string }>(
|
|
85
|
+
`/api/visitor-transactions/self-service/resend/${id}`,
|
|
86
|
+
{ method: "POST", body: email ? { email } : {} },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
return {
|
|
78
91
|
getOnboardingSettings,
|
|
79
92
|
getBlocks,
|
|
@@ -83,5 +96,6 @@ export default function usePublicVisitorOnboarding() {
|
|
|
83
96
|
getOvernightParkingHours,
|
|
84
97
|
getSelfServicePreview,
|
|
85
98
|
getSelfServiceQrCode,
|
|
99
|
+
resendSelfServicePass,
|
|
86
100
|
};
|
|
87
101
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.2.2-staging.
|
|
5
|
+
"version": "4.2.2-staging.266",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure part of the self-service links card.
|
|
3
|
+
*
|
|
4
|
+
* The card hands a lead contractor and each of their members a Copy Link /
|
|
5
|
+
* View Link / Resend row. Which of those rows still has to ask for an email
|
|
6
|
+
* address before Resend can do anything is the whole of the interaction, so it
|
|
7
|
+
* is decided here rather than inline in the template.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type TPassRow = {
|
|
11
|
+
_id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
email?: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The public preview/QR page for one registration. `origin` is empty during
|
|
18
|
+
* the server render (there is no `window`), which correctly yields a relative
|
|
19
|
+
* link - the card only ever renders it into an href or the clipboard, both of
|
|
20
|
+
* which resolve it against the page the visitor is already on.
|
|
21
|
+
*/
|
|
22
|
+
export function passLink(origin: string, id: string): string {
|
|
23
|
+
return `${origin}/public-view/visitor-onboarding/pass/${id}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when this row has nobody to send to yet, which is the card's cue to
|
|
28
|
+
* open its inline email field instead of sending. A whitespace-only value is
|
|
29
|
+
* no address - the API would refuse it.
|
|
30
|
+
*/
|
|
31
|
+
export function needsEmailInput(row: TPassRow): boolean {
|
|
32
|
+
return !String(row.email ?? "").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Deliberately the same loose check the API applies
|
|
39
|
+
* (`isLikelySelfServiceEmail` in @7365admin1/core), so the Send button is not
|
|
40
|
+
* offered for something the server will only refuse. The server stays the
|
|
41
|
+
* authority; this just avoids a pointless round trip.
|
|
42
|
+
*/
|
|
43
|
+
export function isLikelyEmail(value?: string | null): boolean {
|
|
44
|
+
return EMAIL_PATTERN.test(String(value ?? "").trim());
|
|
45
|
+
}
|