@7365admin1/layer-common 4.2.2-staging.264 → 4.2.2-staging.265

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.
@@ -46,7 +46,7 @@
46
46
  label="Email"
47
47
  density="comfortable"
48
48
  variant="outlined"
49
- hide-details
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.value = draftMembers.value.filter((m) => m.name && m.contact);
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
- <div class="link-row pa-3 mb-3">
18
- <p class="text-body-2 font-weight-medium mb-2">{{ leadName }}</p>
19
- <div class="d-flex ga-2">
20
- <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(leadLink)">
21
- Copy Link
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(m._id))">
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(m._id)" target="_blank">
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 `${origin}/public-view/visitor-onboarding/pass/${id}`;
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.264",
5
+ "version": "4.2.2-staging.265",
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
+ }