@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.
- package/components/PublicOnboarding/ContractorRegistrationForm.vue +17 -5
- package/components/PublicOnboarding/ShareDialog.vue +93 -0
- package/components/PublicOnboarding/VisitorQrCodeLinksCard.vue +113 -0
- package/components/VehicleManagement.vue +1 -1
- package/components/VisitorManagement.vue +1 -1
- package/composables/usePublicVisitorOnboarding.ts +14 -0
- package/package.json +1 -1
|
@@ -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' ? '
|
|
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.
|
|
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.",
|