pukaad-ui-lib 1.346.0 → 1.348.0
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/dist/module.json +1 -1
- package/dist/runtime/components/drawer/drawer-notification.d.vue.ts +33 -0
- package/dist/runtime/components/drawer/drawer-notification.vue +261 -0
- package/dist/runtime/components/drawer/drawer-notification.vue.d.ts +33 -0
- package/dist/runtime/components/ui/native-select/NativeSelectOptGroup.d.vue.ts +2 -2
- package/dist/runtime/components/ui/native-select/NativeSelectOptGroup.vue.d.ts +2 -2
- package/dist/runtime/components/ui/native-select/NativeSelectOption.d.vue.ts +2 -2
- package/dist/runtime/components/ui/native-select/NativeSelectOption.vue.d.ts +2 -2
- package/dist/runtime/composables/store-notification.d.ts +31 -0
- package/dist/runtime/composables/store-notification.js +61 -0
- package/dist/runtime/composables/useRealtime.d.ts +10 -0
- package/dist/runtime/composables/useRealtime.js +127 -0
- package/dist/runtime/utils/websocket.d.ts +1 -0
- package/dist/runtime/utils/websocket.js +15 -0
- package/package.json +1 -1
- /package/dist/runtime/assets/svg/socials/{WhatsApp.svg → Whatsapp.svg} +0 -0
package/dist/module.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface NotificationActor {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
avatar: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface NotificationRef {
|
|
7
|
+
type: string;
|
|
8
|
+
id: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NotificationItem {
|
|
11
|
+
id: string;
|
|
12
|
+
category: string;
|
|
13
|
+
action: string;
|
|
14
|
+
message: string;
|
|
15
|
+
ref?: NotificationRef;
|
|
16
|
+
is_read: boolean;
|
|
17
|
+
actor?: NotificationActor;
|
|
18
|
+
grouped_count?: number;
|
|
19
|
+
created_at: string;
|
|
20
|
+
}
|
|
21
|
+
declare const modelValue: import("vue").ModelRef<boolean | undefined, string, boolean | undefined, boolean | undefined>;
|
|
22
|
+
type __VLS_ModelProps = {
|
|
23
|
+
modelValue?: typeof modelValue['value'];
|
|
24
|
+
};
|
|
25
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
26
|
+
select: (item: NotificationItem) => any;
|
|
27
|
+
"update:modelValue": (value: boolean | undefined) => any;
|
|
28
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
|
|
29
|
+
onSelect?: ((item: NotificationItem) => any) | undefined;
|
|
30
|
+
"onUpdate:modelValue"?: ((value: boolean | undefined) => any) | undefined;
|
|
31
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
32
|
+
declare const _default: typeof __VLS_export;
|
|
33
|
+
export default _default;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Drawer v-model="modelValue" title="การแจ้งเตือน" class="w-[748px]">
|
|
3
|
+
<div class="flex justify-between items-center mb-[16px]">
|
|
4
|
+
<ShadTabs v-model="selectedTab" class="w-full">
|
|
5
|
+
<div class="flex justify-between items-center w-full">
|
|
6
|
+
<ShadTabsList>
|
|
7
|
+
<ShadTabsTrigger
|
|
8
|
+
v-for="tab in tabs"
|
|
9
|
+
:key="tab.value"
|
|
10
|
+
:value="tab.value"
|
|
11
|
+
class="px-4 py-2"
|
|
12
|
+
>
|
|
13
|
+
<Icon name="lucide:layers" />
|
|
14
|
+
<div class="flex">
|
|
15
|
+
{{ tab.label }}
|
|
16
|
+
<div
|
|
17
|
+
v-if="
|
|
18
|
+
tab.value === 'general' ? hasUnreadGeneral : hasUnreadSystem
|
|
19
|
+
"
|
|
20
|
+
class="bg-destructive rounded-full w-[10px] h-[10px] m-[2px] shrink-0"
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
</ShadTabsTrigger>
|
|
24
|
+
</ShadTabsList>
|
|
25
|
+
|
|
26
|
+
<div
|
|
27
|
+
v-if="notifications.length > 0"
|
|
28
|
+
class="text-primary font-body-medium cursor-pointer"
|
|
29
|
+
@click="markAllRead"
|
|
30
|
+
>
|
|
31
|
+
อ่านทั้งหมด
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<ShadTabsContent value="general" class="flex flex-col gap-[8px]">
|
|
36
|
+
<template v-if="isLoading && notifications.length === 0">
|
|
37
|
+
<div class="flex justify-center py-[40px]">
|
|
38
|
+
<Icon
|
|
39
|
+
name="lucide:loader-2"
|
|
40
|
+
size="24"
|
|
41
|
+
class="animate-spin text-gray"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<template v-else-if="notifications.length === 0">
|
|
47
|
+
<div
|
|
48
|
+
class="flex flex-col items-center justify-center py-[40px] gap-[8px]"
|
|
49
|
+
>
|
|
50
|
+
<Icon name="lucide:bell-off" size="40" class="text-gray" />
|
|
51
|
+
<div class="font-body-medium text-gray">ยังไม่มีการแจ้งเตือน</div>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<template v-else>
|
|
56
|
+
<Card
|
|
57
|
+
v-for="item in notifications"
|
|
58
|
+
:key="item.id"
|
|
59
|
+
variant="outline"
|
|
60
|
+
class="cursor-pointer transition-colors"
|
|
61
|
+
:class="{ 'bg-bright': !item.is_read }"
|
|
62
|
+
@click="onClickNotification(item)"
|
|
63
|
+
>
|
|
64
|
+
<div class="flex gap-[8px] items-center">
|
|
65
|
+
<div class="flex gap-[8px] items-center w-full">
|
|
66
|
+
<Avatar :size="30" :src="item.actor?.avatar ?? void 0" />
|
|
67
|
+
<div class="flex flex-col gap-[4px]">
|
|
68
|
+
<div class="font-body-medium">
|
|
69
|
+
<span
|
|
70
|
+
v-if="item.actor"
|
|
71
|
+
class="font-body-medium-prominent"
|
|
72
|
+
>
|
|
73
|
+
{{ item.actor.name }}
|
|
74
|
+
</span>
|
|
75
|
+
{{ item.message }}
|
|
76
|
+
<span
|
|
77
|
+
v-if="item.grouped_count && item.grouped_count > 1"
|
|
78
|
+
class="text-gray font-body-small"
|
|
79
|
+
>
|
|
80
|
+
({{ item.grouped_count }} คน)
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="text-gray font-body-small">
|
|
84
|
+
{{ formatRelativeTime(item.created_at) }}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div
|
|
89
|
+
v-if="!item.is_read"
|
|
90
|
+
class="bg-primary rounded-full w-[10px] h-[10px] shrink-0"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
</Card>
|
|
94
|
+
</template>
|
|
95
|
+
</ShadTabsContent>
|
|
96
|
+
|
|
97
|
+
<ShadTabsContent value="system" class="flex flex-col gap-[8px]">
|
|
98
|
+
<template v-if="isLoading && notifications.length === 0">
|
|
99
|
+
<div class="flex justify-center py-[40px]">
|
|
100
|
+
<Icon
|
|
101
|
+
name="lucide:loader-2"
|
|
102
|
+
size="24"
|
|
103
|
+
class="animate-spin text-gray"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<template v-else-if="notifications.length === 0">
|
|
109
|
+
<div
|
|
110
|
+
class="flex flex-col items-center justify-center py-[40px] gap-[8px]"
|
|
111
|
+
>
|
|
112
|
+
<Icon name="lucide:bell-off" size="40" class="text-gray" />
|
|
113
|
+
<div class="font-body-medium text-gray">ยังไม่มีการแจ้งเตือนระบบ</div>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<template v-else>
|
|
118
|
+
<Card
|
|
119
|
+
v-for="item in notifications"
|
|
120
|
+
:key="item.id"
|
|
121
|
+
variant="outline"
|
|
122
|
+
class="cursor-pointer transition-colors"
|
|
123
|
+
:class="{ 'bg-bright': !item.is_read }"
|
|
124
|
+
@click="onClickNotification(item)"
|
|
125
|
+
>
|
|
126
|
+
<div class="flex gap-[8px] items-center">
|
|
127
|
+
<div class="flex gap-[8px] items-center w-full">
|
|
128
|
+
<Icon
|
|
129
|
+
name="pukaad:memsg-square"
|
|
130
|
+
class="w-[40px] h-[40px] shrink-0"
|
|
131
|
+
/>
|
|
132
|
+
<div class="flex flex-col gap-[4px]">
|
|
133
|
+
<div class="font-body-medium">{{ item.message }}</div>
|
|
134
|
+
<div class="text-gray font-body-small">
|
|
135
|
+
{{ formatRelativeTime(item.created_at) }}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div
|
|
140
|
+
v-if="!item.is_read"
|
|
141
|
+
class="bg-primary rounded-full w-[10px] h-[10px] shrink-0"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</Card>
|
|
145
|
+
</template>
|
|
146
|
+
</ShadTabsContent>
|
|
147
|
+
</ShadTabs>
|
|
148
|
+
</div>
|
|
149
|
+
</Drawer>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<script setup>
|
|
153
|
+
import { ref, computed, watch } from "vue";
|
|
154
|
+
import { useApi } from "../../composables/useApi";
|
|
155
|
+
import { useNotificationStore } from "../../composables/store-notification";
|
|
156
|
+
const api = useApi();
|
|
157
|
+
const modelValue = defineModel({ type: Boolean });
|
|
158
|
+
const notificationStore = useNotificationStore();
|
|
159
|
+
const emit = defineEmits(["select"]);
|
|
160
|
+
const selectedTab = ref("general");
|
|
161
|
+
const tabs = [
|
|
162
|
+
{ value: "general", label: "\u0E17\u0E31\u0E48\u0E27\u0E44\u0E1B" },
|
|
163
|
+
{ value: "system", label: "\u0E23\u0E30\u0E1A\u0E1A" }
|
|
164
|
+
];
|
|
165
|
+
const generalNotifications = ref([]);
|
|
166
|
+
const systemNotifications = ref([]);
|
|
167
|
+
const isLoading = ref(false);
|
|
168
|
+
const notifications = computed(
|
|
169
|
+
() => selectedTab.value === "system" ? systemNotifications.value : generalNotifications.value
|
|
170
|
+
);
|
|
171
|
+
const hasUnreadGeneral = computed(
|
|
172
|
+
() => generalNotifications.value.some((n) => !n.is_read)
|
|
173
|
+
);
|
|
174
|
+
const hasUnreadSystem = computed(
|
|
175
|
+
() => systemNotifications.value.some((n) => !n.is_read)
|
|
176
|
+
);
|
|
177
|
+
const fetchNotifications = async () => {
|
|
178
|
+
try {
|
|
179
|
+
isLoading.value = true;
|
|
180
|
+
if (selectedTab.value === "system") {
|
|
181
|
+
const response = await api(`/notifications`, {
|
|
182
|
+
params: { page: 1, page_size: 50, category: "system" }
|
|
183
|
+
});
|
|
184
|
+
systemNotifications.value = response.data;
|
|
185
|
+
} else {
|
|
186
|
+
const response = await api(`/notifications`, {
|
|
187
|
+
params: { page: 1, page_size: 50 }
|
|
188
|
+
});
|
|
189
|
+
generalNotifications.value = response.data.filter(
|
|
190
|
+
(n) => n.category !== "system"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error("Fetch notifications error:", err);
|
|
195
|
+
} finally {
|
|
196
|
+
isLoading.value = false;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const markAsRead = async (item) => {
|
|
200
|
+
if (item.is_read) return;
|
|
201
|
+
try {
|
|
202
|
+
await api(`/notifications/${item.id}/read`, { method: "PATCH" });
|
|
203
|
+
item.is_read = true;
|
|
204
|
+
const totalUnread = generalNotifications.value.filter((n) => !n.is_read).length + systemNotifications.value.filter((n) => !n.is_read).length;
|
|
205
|
+
notificationStore.setUnreadCount(totalUnread);
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const markAllRead = async () => {
|
|
210
|
+
try {
|
|
211
|
+
await api(`/notifications/read-all`, { method: "PATCH" });
|
|
212
|
+
generalNotifications.value.forEach((n) => n.is_read = true);
|
|
213
|
+
systemNotifications.value.forEach((n) => n.is_read = true);
|
|
214
|
+
notificationStore.setUnreadCount(0);
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const onClickNotification = (item) => {
|
|
219
|
+
markAsRead(item);
|
|
220
|
+
emit("select", item);
|
|
221
|
+
};
|
|
222
|
+
const formatRelativeTime = (dateStr) => {
|
|
223
|
+
const now = /* @__PURE__ */ new Date();
|
|
224
|
+
const date = new Date(dateStr);
|
|
225
|
+
const diffMs = now.getTime() - date.getTime();
|
|
226
|
+
const diffSec = Math.floor(diffMs / 1e3);
|
|
227
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
228
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
229
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
230
|
+
if (diffSec < 60) return "\u0E40\u0E21\u0E37\u0E48\u0E2D\u0E0B\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48";
|
|
231
|
+
if (diffMin < 60) return `${diffMin} \u0E19\u0E32\u0E17\u0E35\u0E17\u0E35\u0E48\u0E41\u0E25\u0E49\u0E27`;
|
|
232
|
+
if (diffHour < 24) return `${diffHour} \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E41\u0E25\u0E49\u0E27`;
|
|
233
|
+
if (diffDay < 7) return `${diffDay} \u0E27\u0E31\u0E19\u0E17\u0E35\u0E48\u0E41\u0E25\u0E49\u0E27`;
|
|
234
|
+
return date.toLocaleDateString("th-TH", {
|
|
235
|
+
day: "numeric",
|
|
236
|
+
month: "short",
|
|
237
|
+
year: "numeric"
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
watch(
|
|
241
|
+
() => notificationStore.isOpenDrawer,
|
|
242
|
+
(val) => {
|
|
243
|
+
if (val) {
|
|
244
|
+
fetchNotifications();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
watch(selectedTab, () => {
|
|
249
|
+
if (notificationStore.isOpenDrawer) {
|
|
250
|
+
fetchNotifications();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
watch(
|
|
254
|
+
() => notificationStore.unreadCount,
|
|
255
|
+
() => {
|
|
256
|
+
if (notificationStore.isOpenDrawer) {
|
|
257
|
+
fetchNotifications();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface NotificationActor {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
avatar: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface NotificationRef {
|
|
7
|
+
type: string;
|
|
8
|
+
id: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NotificationItem {
|
|
11
|
+
id: string;
|
|
12
|
+
category: string;
|
|
13
|
+
action: string;
|
|
14
|
+
message: string;
|
|
15
|
+
ref?: NotificationRef;
|
|
16
|
+
is_read: boolean;
|
|
17
|
+
actor?: NotificationActor;
|
|
18
|
+
grouped_count?: number;
|
|
19
|
+
created_at: string;
|
|
20
|
+
}
|
|
21
|
+
declare const modelValue: import("vue").ModelRef<boolean | undefined, string, boolean | undefined, boolean | undefined>;
|
|
22
|
+
type __VLS_ModelProps = {
|
|
23
|
+
modelValue?: typeof modelValue['value'];
|
|
24
|
+
};
|
|
25
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
26
|
+
select: (item: NotificationItem) => any;
|
|
27
|
+
"update:modelValue": (value: boolean | undefined) => any;
|
|
28
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
|
|
29
|
+
onSelect?: ((item: NotificationItem) => any) | undefined;
|
|
30
|
+
"onUpdate:modelValue"?: ((value: boolean | undefined) => any) | undefined;
|
|
31
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
32
|
+
declare const _default: typeof __VLS_export;
|
|
33
|
+
export default _default;
|
|
@@ -14,7 +14,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
14
14
|
dir?: string | undefined | undefined;
|
|
15
15
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
16
16
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
17
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
17
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
18
18
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
19
19
|
id?: string | undefined | undefined;
|
|
20
20
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -208,7 +208,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
208
208
|
dir?: string | undefined | undefined;
|
|
209
209
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
210
210
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
211
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
211
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
212
212
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
213
213
|
id?: string | undefined | undefined;
|
|
214
214
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -14,7 +14,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
14
14
|
dir?: string | undefined | undefined;
|
|
15
15
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
16
16
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
17
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
17
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
18
18
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
19
19
|
id?: string | undefined | undefined;
|
|
20
20
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -208,7 +208,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
208
208
|
dir?: string | undefined | undefined;
|
|
209
209
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
210
210
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
211
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
211
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
212
212
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
213
213
|
id?: string | undefined | undefined;
|
|
214
214
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -16,7 +16,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
16
16
|
dir?: string | undefined | undefined;
|
|
17
17
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
18
18
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
19
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
19
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
20
20
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
21
21
|
id?: string | undefined | undefined;
|
|
22
22
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -212,7 +212,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
212
212
|
dir?: string | undefined | undefined;
|
|
213
213
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
214
214
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
215
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
215
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
216
216
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
217
217
|
id?: string | undefined | undefined;
|
|
218
218
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -16,7 +16,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
16
16
|
dir?: string | undefined | undefined;
|
|
17
17
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
18
18
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
19
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
19
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
20
20
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
21
21
|
id?: string | undefined | undefined;
|
|
22
22
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -212,7 +212,7 @@ declare const __VLS_base: import("vue").DefineComponent<{
|
|
|
212
212
|
dir?: string | undefined | undefined;
|
|
213
213
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
214
214
|
enterkeyhint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
215
|
-
enterKeyHint?: "search" | "done" | "next" | "
|
|
215
|
+
enterKeyHint?: "search" | "done" | "next" | "send" | "enter" | "go" | "previous" | undefined;
|
|
216
216
|
hidden?: "" | (boolean | "true" | "false") | "hidden" | "until-found" | undefined;
|
|
217
217
|
id?: string | undefined | undefined;
|
|
218
218
|
inert?: (boolean | "true" | "false") | undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const useNotificationStore: import("pinia").StoreDefinition<"notification", Pick<{
|
|
2
|
+
unreadCount: import("vue").Ref<number, number>;
|
|
3
|
+
isOpenDrawer: import("vue").Ref<boolean, boolean>;
|
|
4
|
+
fetchUnreadCount: () => Promise<void>;
|
|
5
|
+
initRealtime: () => void;
|
|
6
|
+
closeRealtime: () => void;
|
|
7
|
+
setUnreadCount: (count: number) => void;
|
|
8
|
+
openDrawer: () => void;
|
|
9
|
+
closeDrawer: () => void;
|
|
10
|
+
toggleDrawer: () => void;
|
|
11
|
+
}, "unreadCount" | "isOpenDrawer">, Pick<{
|
|
12
|
+
unreadCount: import("vue").Ref<number, number>;
|
|
13
|
+
isOpenDrawer: import("vue").Ref<boolean, boolean>;
|
|
14
|
+
fetchUnreadCount: () => Promise<void>;
|
|
15
|
+
initRealtime: () => void;
|
|
16
|
+
closeRealtime: () => void;
|
|
17
|
+
setUnreadCount: (count: number) => void;
|
|
18
|
+
openDrawer: () => void;
|
|
19
|
+
closeDrawer: () => void;
|
|
20
|
+
toggleDrawer: () => void;
|
|
21
|
+
}, never>, Pick<{
|
|
22
|
+
unreadCount: import("vue").Ref<number, number>;
|
|
23
|
+
isOpenDrawer: import("vue").Ref<boolean, boolean>;
|
|
24
|
+
fetchUnreadCount: () => Promise<void>;
|
|
25
|
+
initRealtime: () => void;
|
|
26
|
+
closeRealtime: () => void;
|
|
27
|
+
setUnreadCount: (count: number) => void;
|
|
28
|
+
openDrawer: () => void;
|
|
29
|
+
closeDrawer: () => void;
|
|
30
|
+
toggleDrawer: () => void;
|
|
31
|
+
}, "fetchUnreadCount" | "initRealtime" | "closeRealtime" | "setUnreadCount" | "openDrawer" | "closeDrawer" | "toggleDrawer">>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineStore } from "pinia";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { useApi } from "./useApi.js";
|
|
4
|
+
import { useRealtime } from "./useRealtime.js";
|
|
5
|
+
export const useNotificationStore = defineStore("notification", () => {
|
|
6
|
+
const api = useApi();
|
|
7
|
+
const { onEvent, offEvent } = useRealtime();
|
|
8
|
+
const unreadCount = ref(0);
|
|
9
|
+
const isOpenDrawer = ref(false);
|
|
10
|
+
const fetchUnreadCount = async () => {
|
|
11
|
+
try {
|
|
12
|
+
const response = await api(
|
|
13
|
+
`/notifications/unread-count`
|
|
14
|
+
);
|
|
15
|
+
unreadCount.value = response.data.count;
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error("Failed to fetch unread count", err);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const onNotificationEvent = (_payload) => {
|
|
21
|
+
const payload = _payload;
|
|
22
|
+
if (typeof payload.unread_count === "number") {
|
|
23
|
+
unreadCount.value = payload.unread_count;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (payload?.action === "delete") {
|
|
27
|
+
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
unreadCount.value++;
|
|
31
|
+
};
|
|
32
|
+
const initRealtime = () => {
|
|
33
|
+
onEvent("notification", onNotificationEvent);
|
|
34
|
+
};
|
|
35
|
+
const closeRealtime = () => {
|
|
36
|
+
offEvent("notification", onNotificationEvent);
|
|
37
|
+
};
|
|
38
|
+
const setUnreadCount = (count) => {
|
|
39
|
+
unreadCount.value = count;
|
|
40
|
+
};
|
|
41
|
+
const openDrawer = () => {
|
|
42
|
+
isOpenDrawer.value = true;
|
|
43
|
+
};
|
|
44
|
+
const closeDrawer = () => {
|
|
45
|
+
isOpenDrawer.value = false;
|
|
46
|
+
};
|
|
47
|
+
const toggleDrawer = () => {
|
|
48
|
+
isOpenDrawer.value = !isOpenDrawer.value;
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
unreadCount,
|
|
52
|
+
isOpenDrawer,
|
|
53
|
+
fetchUnreadCount,
|
|
54
|
+
initRealtime,
|
|
55
|
+
closeRealtime,
|
|
56
|
+
setUnreadCount,
|
|
57
|
+
openDrawer,
|
|
58
|
+
closeDrawer,
|
|
59
|
+
toggleDrawer
|
|
60
|
+
};
|
|
61
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type EventCallback = (payload: unknown) => void;
|
|
2
|
+
export declare const useRealtime: () => {
|
|
3
|
+
connect: () => void;
|
|
4
|
+
disconnect: () => void;
|
|
5
|
+
send: (event: string, payload: unknown) => void;
|
|
6
|
+
onEvent: (event: string, callback: EventCallback) => void;
|
|
7
|
+
offEvent: (event: string, callback: EventCallback) => void;
|
|
8
|
+
isConnected: import("vue").Ref<boolean, boolean>;
|
|
9
|
+
};
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ref, getCurrentInstance, onBeforeUnmount } from "vue";
|
|
2
|
+
import { useRuntimeConfig } from "nuxt/app";
|
|
3
|
+
import { createAuthenticatedWebSocket } from "../utils/websocket.js";
|
|
4
|
+
const socket = ref(null);
|
|
5
|
+
const isConnected = ref(false);
|
|
6
|
+
const reconnectAttempts = ref(0);
|
|
7
|
+
const maxReconnectDelay = 3e4;
|
|
8
|
+
const baseReconnectDelay = 1e3;
|
|
9
|
+
let reconnectTimeout = null;
|
|
10
|
+
let isManuallyClosed = false;
|
|
11
|
+
const eventListeners = /* @__PURE__ */ new Map();
|
|
12
|
+
export const useRealtime = () => {
|
|
13
|
+
const { BASE_URL_API } = useRuntimeConfig().public;
|
|
14
|
+
const onEvent = (event, callback) => {
|
|
15
|
+
if (!eventListeners.has(event)) {
|
|
16
|
+
eventListeners.set(event, /* @__PURE__ */ new Set());
|
|
17
|
+
}
|
|
18
|
+
eventListeners.get(event).add(callback);
|
|
19
|
+
};
|
|
20
|
+
const offEvent = (event, callback) => {
|
|
21
|
+
eventListeners.get(event)?.delete(callback);
|
|
22
|
+
};
|
|
23
|
+
const getWsUrl = () => {
|
|
24
|
+
let url = BASE_URL_API;
|
|
25
|
+
if (typeof window !== "undefined" && window.location.hostname !== "localhost") {
|
|
26
|
+
url = url.replace("localhost", window.location.hostname).replace("127.0.0.1", window.location.hostname);
|
|
27
|
+
}
|
|
28
|
+
if (url.startsWith("http://")) {
|
|
29
|
+
url = url.replace("http://", "ws://");
|
|
30
|
+
} else if (url.startsWith("https://")) {
|
|
31
|
+
url = url.replace("https://", "wss://");
|
|
32
|
+
}
|
|
33
|
+
return `${url}/ws`;
|
|
34
|
+
};
|
|
35
|
+
const connect = () => {
|
|
36
|
+
if (socket.value?.readyState === WebSocket.OPEN || socket.value?.readyState === WebSocket.CONNECTING) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
isManuallyClosed = false;
|
|
40
|
+
const wsUrl = getWsUrl();
|
|
41
|
+
console.log("Connecting to WS:", wsUrl);
|
|
42
|
+
const ws = createAuthenticatedWebSocket(wsUrl);
|
|
43
|
+
if (!ws) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
socket.value = ws;
|
|
47
|
+
socket.value.onopen = () => {
|
|
48
|
+
console.log("WS Connected");
|
|
49
|
+
isConnected.value = true;
|
|
50
|
+
reconnectAttempts.value = 0;
|
|
51
|
+
};
|
|
52
|
+
socket.value.onmessage = (event) => {
|
|
53
|
+
try {
|
|
54
|
+
const data = JSON.parse(event.data);
|
|
55
|
+
console.log("WS Event:", data);
|
|
56
|
+
handleEvent(data);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.error("WS Parse Error:", e);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
socket.value.onclose = () => {
|
|
62
|
+
console.log("WS Closed");
|
|
63
|
+
isConnected.value = false;
|
|
64
|
+
socket.value = null;
|
|
65
|
+
if (!isManuallyClosed) {
|
|
66
|
+
attemptReconnect();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
socket.value.onerror = (error) => {
|
|
70
|
+
console.error("WS Error:", error);
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const attemptReconnect = () => {
|
|
74
|
+
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
|
75
|
+
const delay = Math.min(
|
|
76
|
+
baseReconnectDelay * Math.pow(2, reconnectAttempts.value),
|
|
77
|
+
maxReconnectDelay
|
|
78
|
+
);
|
|
79
|
+
const jitter = Math.random() * 1e3;
|
|
80
|
+
console.log(
|
|
81
|
+
`Reconnecting in ${delay + jitter}ms... (Attempt ${reconnectAttempts.value + 1})`
|
|
82
|
+
);
|
|
83
|
+
reconnectTimeout = setTimeout(() => {
|
|
84
|
+
reconnectAttempts.value++;
|
|
85
|
+
connect();
|
|
86
|
+
}, delay + jitter);
|
|
87
|
+
};
|
|
88
|
+
const disconnect = () => {
|
|
89
|
+
isManuallyClosed = true;
|
|
90
|
+
if (socket.value) {
|
|
91
|
+
socket.value.close();
|
|
92
|
+
socket.value = null;
|
|
93
|
+
}
|
|
94
|
+
if (reconnectTimeout) {
|
|
95
|
+
clearTimeout(reconnectTimeout);
|
|
96
|
+
reconnectTimeout = null;
|
|
97
|
+
}
|
|
98
|
+
isConnected.value = false;
|
|
99
|
+
};
|
|
100
|
+
const send = (event, payload) => {
|
|
101
|
+
if (socket.value?.readyState === WebSocket.OPEN) {
|
|
102
|
+
socket.value.send(JSON.stringify({ event, payload }));
|
|
103
|
+
} else {
|
|
104
|
+
console.warn("WS: Cannot send, socket is not connected.");
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const handleEvent = (data) => {
|
|
108
|
+
const { event, payload } = data;
|
|
109
|
+
const listeners = eventListeners.get(event);
|
|
110
|
+
if (listeners) {
|
|
111
|
+
listeners.forEach((cb) => cb(payload));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
if (getCurrentInstance()) {
|
|
115
|
+
onBeforeUnmount(() => {
|
|
116
|
+
disconnect();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
connect,
|
|
121
|
+
disconnect,
|
|
122
|
+
send,
|
|
123
|
+
onEvent,
|
|
124
|
+
offEvent,
|
|
125
|
+
isConnected
|
|
126
|
+
};
|
|
127
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const createAuthenticatedWebSocket: (url: string) => WebSocket | null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useRuntimeConfig, useCookie } from "nuxt/app";
|
|
2
|
+
export const createAuthenticatedWebSocket = (url) => {
|
|
3
|
+
const config = useRuntimeConfig();
|
|
4
|
+
const { APP_TYPE } = config.public;
|
|
5
|
+
const secId = useCookie(APP_TYPE === "OFFICE" ? "OFFICE_SEC_ID" : "SEC_ID");
|
|
6
|
+
if (!secId.value) {
|
|
7
|
+
console.warn(
|
|
8
|
+
"WS: No auth token found, cannot create authenticated connection."
|
|
9
|
+
);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const wsUrl = new URL(url);
|
|
13
|
+
wsUrl.searchParams.append("token", secId.value);
|
|
14
|
+
return new WebSocket(wsUrl.toString());
|
|
15
|
+
};
|
package/package.json
CHANGED
|
File without changes
|