@7365admin1/layer-common 4.1.3-staging.249 → 4.1.3-staging.251

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.
@@ -146,7 +146,14 @@
146
146
  hide-details
147
147
  class="mb-2"
148
148
  />
149
- <InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details />
149
+ <InputPhoneNumberV2 v-model="member.contact" density="comfortable" hide-details class="mb-2" />
150
+ <v-text-field
151
+ v-model="member.plateNumber"
152
+ label="Vehicle Number (Optional)"
153
+ density="comfortable"
154
+ variant="outlined"
155
+ hide-details
156
+ />
150
157
  </div>
151
158
 
152
159
  <v-btn variant="outlined" block class="mb-3" prepend-icon="mdi-plus" @click="addDraftMember">
@@ -192,7 +199,7 @@ const visitor = reactive({
192
199
  remarks: "",
193
200
  });
194
201
 
195
- type TDraftMember = { name: string; nric: string; contact: string };
202
+ type TDraftMember = { name: string; nric: string; contact: string; plateNumber: string };
196
203
  const members = ref<TDraftMember[]>([]);
197
204
  const draftMembers = ref<TDraftMember[]>([]);
198
205
  const showMembersDialog = ref(false);
@@ -201,12 +208,12 @@ watch(showMembersDialog, (open) => {
201
208
  if (open) {
202
209
  draftMembers.value = members.value.length
203
210
  ? members.value.map((m) => ({ ...m }))
204
- : [{ name: "", nric: "", contact: "" }];
211
+ : [{ name: "", nric: "", contact: "", plateNumber: "" }];
205
212
  }
206
213
  });
207
214
 
208
215
  function addDraftMember() {
209
- draftMembers.value.push({ name: "", nric: "", contact: "" });
216
+ draftMembers.value.push({ name: "", nric: "", contact: "", plateNumber: "" });
210
217
  }
211
218
 
212
219
  function saveMembers() {
@@ -234,6 +241,7 @@ async function submit() {
234
241
  contractorType: visitor.contractorType || undefined,
235
242
  name: visitor.name,
236
243
  contact: visitor.contact,
244
+ expectedCheckIn: visitor.expectedCheckIn || undefined,
237
245
  nric: visitor.nric || undefined,
238
246
  email: visitor.email || undefined,
239
247
  block: visitor.block || undefined,
@@ -246,7 +254,11 @@ async function submit() {
246
254
  };
247
255
 
248
256
  const res: any = await createVisitor(prop.site, payload);
249
- emit("done", res?.status || "pending");
257
+ emit("done", res?.status || "pending", res?._id, {
258
+ name: visitor.name,
259
+ expectedCheckIn: visitor.expectedCheckIn,
260
+ members: res?.members || [],
261
+ });
250
262
  } catch (error: any) {
251
263
  submitError.value =
252
264
  error?.data?.message || error?.message || "Unable to submit your registration. Please try again.";
@@ -0,0 +1,93 @@
1
+ <template>
2
+ <v-dialog :model-value="modelValue" max-width="380" @update:model-value="emit('update:modelValue', $event)">
3
+ <v-card class="pa-4">
4
+ <div class="d-flex justify-space-between align-center mb-1">
5
+ <span class="text-subtitle-1 font-weight-bold">Share</span>
6
+ <v-btn icon variant="text" size="small" @click="emit('update:modelValue', false)">
7
+ <v-icon>mdi-close</v-icon>
8
+ </v-btn>
9
+ </div>
10
+ <p class="text-caption text-medium-emphasis mb-4">As a message</p>
11
+
12
+ <div class="d-flex ga-4 mb-5">
13
+ <a :href="whatsappUrl" target="_blank" rel="noopener" class="share-icon" aria-label="Share via WhatsApp">
14
+ <v-avatar color="#25D366" size="44"><v-icon color="white">mdi-whatsapp</v-icon></v-avatar>
15
+ </a>
16
+ <a :href="telegramUrl" target="_blank" rel="noopener" class="share-icon" aria-label="Share via Telegram">
17
+ <v-avatar color="#229ED9" size="44"><v-icon color="white">mdi-telegram</v-icon></v-avatar>
18
+ </a>
19
+ <button
20
+ v-if="canNativeShare"
21
+ type="button"
22
+ class="share-icon share-icon--btn"
23
+ aria-label="More share options"
24
+ @click="nativeShare"
25
+ >
26
+ <v-avatar color="grey-darken-2" size="44"><v-icon color="white">mdi-dots-horizontal</v-icon></v-avatar>
27
+ </button>
28
+ </div>
29
+
30
+ <p class="text-caption text-medium-emphasis mb-2">Or copy message</p>
31
+ <div class="d-flex ga-2 align-start">
32
+ <v-textarea :model-value="message" readonly density="compact" variant="outlined" rows="3" hide-details auto-grow />
33
+ <v-btn color="error" variant="flat" @click="copy">COPY</v-btn>
34
+ </div>
35
+
36
+ <v-snackbar v-model="copied" timeout="1800" location="bottom">Copied</v-snackbar>
37
+ </v-card>
38
+ </v-dialog>
39
+ </template>
40
+
41
+ <script lang="ts" setup>
42
+ const props = defineProps<{
43
+ modelValue: boolean;
44
+ message: string;
45
+ link: string;
46
+ }>();
47
+
48
+ const emit = defineEmits<{ (e: "update:modelValue", value: boolean): void }>();
49
+
50
+ // No generic web-share URL exists for Messenger/WeChat without registering
51
+ // an app (Facebook's Send Dialog needs an App ID, WeChat sharing needs its
52
+ // own SDK and is region-gated) - WhatsApp and Telegram both have simple,
53
+ // no-auth share URLs, and the native share sheet (mobile browsers) covers
54
+ // "any other app installed" without us hand-integrating each one.
55
+ const whatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(props.message)}`);
56
+ const telegramUrl = computed(
57
+ () => `https://t.me/share/url?url=${encodeURIComponent(props.link)}&text=${encodeURIComponent(props.message)}`,
58
+ );
59
+
60
+ const canNativeShare = computed(() => import.meta.client && typeof navigator !== "undefined" && !!navigator.share);
61
+
62
+ async function nativeShare() {
63
+ try {
64
+ await navigator.share({ text: props.message, url: props.link });
65
+ } catch {
66
+ // User cancelled the share sheet, or the browser refused - no action
67
+ // needed, the dialog stays open with the other options still available.
68
+ }
69
+ }
70
+
71
+ const copied = ref(false);
72
+
73
+ async function copy() {
74
+ try {
75
+ await navigator.clipboard.writeText(props.message);
76
+ copied.value = true;
77
+ } catch {
78
+ // Clipboard API can be unavailable - copy-icon fallback isn't worth
79
+ // the added complexity here, View Link on the card still works.
80
+ }
81
+ }
82
+ </script>
83
+
84
+ <style scoped>
85
+ .share-icon {
86
+ display: inline-flex;
87
+ text-decoration: none;
88
+ border: none;
89
+ background: none;
90
+ padding: 0;
91
+ cursor: pointer;
92
+ }
93
+ </style>
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <v-card class="pa-6" elevation="2">
3
+ <div class="d-flex flex-column align-center mb-4">
4
+ <v-avatar color="grey-darken-3" size="48" class="mb-3">
5
+ <v-icon icon="mdi-qrcode" color="white" size="24" />
6
+ </v-avatar>
7
+ <h1 class="text-subtitle-1 font-weight-bold text-center">Visitor QR Code Links</h1>
8
+ <p class="text-caption text-medium-emphasis text-center mt-1">
9
+ {{ status === "approved" ? "Invitation generated successful!" : "Registration submitted - awaiting approval" }}
10
+ </p>
11
+ </div>
12
+
13
+ <p class="text-body-2 mb-4">
14
+ Hi {{ leadName }}, here {{ members.length ? "are the check-in links for you and your team" : "is your check-in link" }}.
15
+ </p>
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>
28
+
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
+ <div class="d-flex ga-2">
34
+ <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" @click="copyLink(linkFor(m._id))">
35
+ Copy Link
36
+ </v-btn>
37
+ <v-btn variant="outlined" color="error" size="small" class="flex-grow-1" :href="linkFor(m._id)" target="_blank">
38
+ View Link
39
+ </v-btn>
40
+ </div>
41
+ </div>
42
+ </template>
43
+
44
+ <v-btn color="error" variant="flat" block size="large" class="mt-2" @click="showShare = true">
45
+ Share
46
+ </v-btn>
47
+
48
+ <v-snackbar v-model="copied" timeout="1800" location="bottom">Link copied</v-snackbar>
49
+
50
+ <PublicOnboardingShareDialog v-model="showShare" :message="shareMessage" :link="leadLink" />
51
+ </v-card>
52
+ </template>
53
+
54
+ <script lang="ts" setup>
55
+ const props = defineProps<{
56
+ leadId: string;
57
+ leadName: string;
58
+ status: string;
59
+ expectedCheckIn?: string | null;
60
+ members: { _id: string; name: string }[];
61
+ }>();
62
+
63
+ function linkFor(id: string) {
64
+ const origin = import.meta.client ? window.location.origin : "";
65
+ return `${origin}/public-view/visitor-onboarding/pass/${id}`;
66
+ }
67
+
68
+ const leadLink = computed(() => linkFor(props.leadId));
69
+
70
+ const arrivalDateDisplay = computed(() => {
71
+ if (!props.expectedCheckIn) return "";
72
+ const d = new Date(props.expectedCheckIn);
73
+ if (Number.isNaN(d.getTime())) return "";
74
+ return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
75
+ });
76
+
77
+ const shareMessage = computed(() => {
78
+ const lines = [
79
+ `Hi ${props.leadName},`,
80
+ "",
81
+ `Your visit registration has been submitted${props.members.length ? " along with your team" : ""}.`,
82
+ ];
83
+ if (arrivalDateDisplay.value) lines.push("", `Arrival: ${arrivalDateDisplay.value}`);
84
+ lines.push("", `Link: ${leadLink.value}`);
85
+ if (props.members.length) {
86
+ lines.push("", "Team Members:");
87
+ for (const m of props.members) {
88
+ lines.push(m.name, `Link: ${linkFor(m._id)}`, "");
89
+ }
90
+ }
91
+ return lines.join("\n");
92
+ });
93
+
94
+ const showShare = ref(false);
95
+ const copied = ref(false);
96
+
97
+ async function copyLink(link: string) {
98
+ try {
99
+ await navigator.clipboard.writeText(link);
100
+ copied.value = true;
101
+ } catch {
102
+ // Clipboard API can be unavailable (insecure context, permission
103
+ // denied) - the visitor still has View Link as a fallback.
104
+ }
105
+ }
106
+ </script>
107
+
108
+ <style scoped>
109
+ .link-row {
110
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
111
+ border-radius: 4px;
112
+ }
113
+ </style>
@@ -109,7 +109,7 @@
109
109
  @click="handleRestoreVehicle(plateNumberItem as TPlateNumber)" />
110
110
  <v-list-item
111
111
  v-if="((plateNumberItem as TPlateNumber)?.type === 'whitelist' || (plateNumberItem as TPlateNumber)?.type === 'blocklist') && plateNumberItem?.status == 'active'"
112
- :title="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'Whitelist' : 'Blocklist'"
112
+ :title="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'Unblock' : 'Block'"
113
113
  prepend-icon="mdi-swap-horizontal"
114
114
  :base-color="(plateNumberItem as TPlateNumber)?.type === 'blocklist' ? 'primary' : 'error'"
115
115
  @click="handleUpdateType(plateNumberItem as TPlateNumber)" />
@@ -428,7 +428,7 @@
428
428
  {{ item.arrivalTime ?? "N/A" }}
429
429
  </span>
430
430
 
431
- <span v-if="!item.checkIn && canUpdateVisitor">
431
+ <span v-if="!item.checkIn && item?.status !== 'pending' && item?.status !== 'rejected' && canUpdateVisitor">
432
432
  <span class="d-flex align-center ga-2 cursor-pointer">
433
433
  <v-icon
434
434
  icon="mdi-clock-time-eight-outline"
@@ -61,6 +61,19 @@ export default function usePublicVisitorOnboarding() {
61
61
  );
62
62
  }
63
63
 
64
+ // The QR the preview page renders once approved - the SAME `qrCodeId`
65
+ // check-in credential a staff-invited visitor gets (scanned by a guard
66
+ // through ScanVisitorQRCode.vue, same check-in dialog/flow). It's minted
67
+ // (or reused if still valid) on every call and expires 5 minutes after
68
+ // being minted, so the preview page re-calls this periodically rather
69
+ // than fetching it once.
70
+ function getSelfServiceQrCode(id: string) {
71
+ return useNuxtApp().$api<Record<string, any>>(
72
+ `/api/visitors/self-service/qr-code/${id}`,
73
+ { method: "GET" },
74
+ );
75
+ }
76
+
64
77
  return {
65
78
  getOnboardingSettings,
66
79
  getBlocks,
@@ -69,5 +82,6 @@ export default function usePublicVisitorOnboarding() {
69
82
  createVisitor,
70
83
  getOvernightParkingHours,
71
84
  getSelfServicePreview,
85
+ getSelfServiceQrCode,
72
86
  };
73
87
  }
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.1.3-staging.249",
5
+ "version": "4.1.3-staging.251",
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.",