pukaad-ui-lib 1.345.0 → 1.347.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pukaad-ui-lib",
3
3
  "configKey": "pukaadUI",
4
- "version": "1.345.0",
4
+ "version": "1.347.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -34,7 +34,11 @@ declare var __VLS_13: {
34
34
  index: number;
35
35
  }, __VLS_106: `item-${string}`, __VLS_107: {
36
36
  item: Record<string, any>;
37
- }, __VLS_109: {}, __VLS_117: {};
37
+ }, __VLS_109: {
38
+ item: Record<string, any>;
39
+ index: number;
40
+ colspan: number;
41
+ }, __VLS_111: {}, __VLS_119: {};
38
42
  type __VLS_Slots = {} & {
39
43
  [K in NonNullable<typeof __VLS_41>]?: (props: typeof __VLS_42) => any;
40
44
  } & {
@@ -46,9 +50,11 @@ type __VLS_Slots = {} & {
46
50
  } & {
47
51
  item?: (props: typeof __VLS_97) => any;
48
52
  } & {
49
- empty?: (props: typeof __VLS_109) => any;
53
+ 'row-banner'?: (props: typeof __VLS_109) => any;
54
+ } & {
55
+ empty?: (props: typeof __VLS_111) => any;
50
56
  } & {
51
- 'empty-content'?: (props: typeof __VLS_117) => any;
57
+ 'empty-content'?: (props: typeof __VLS_119) => any;
52
58
  };
53
59
  declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
54
60
  "update:page": (value: number) => any;
@@ -76,6 +76,9 @@ const itemHeaders = computed(() => {
76
76
  );
77
77
  return header;
78
78
  });
79
+ const totalColumns = computed(
80
+ () => itemHeaders.value.length + (props.selectable ? 1 : 0)
81
+ );
79
82
  const sortedItems = computed(() => {
80
83
  return props.items.slice().sort((a, b) => {
81
84
  if (!sortkey.value) return 0;
@@ -197,49 +200,58 @@ const getJustifyClass = (alignment = "start") => {
197
200
 
198
201
  <!-- Data Rows -->
199
202
  <template v-else-if="sortedItems.length > 0">
200
- <ShadTableRow
201
- v-for="(item, i_body) in sortedItems"
202
- :key="i_body"
203
- class="cursor-pointer"
204
- :data-state="
203
+ <template v-for="(item, i_body) in sortedItems" :key="i_body">
204
+ <ShadTableRow
205
+ class="cursor-pointer"
206
+ :data-state="
205
207
  props.selectable && isSelected(item) ? 'selected' : void 0
206
208
  "
207
- @click="emit('click-row', item, i_body)"
208
- >
209
- <!-- Checkbox per row -->
210
- <ShadTableCell v-if="props.selectable" @click.stop>
211
- <div class="flex items-center justify-center">
212
- <InputCheckbox
213
- :model-value="isSelected(item)"
214
- @update:model-value="() => toggleRow(item)"
215
- />
216
- </div>
217
- </ShadTableCell>
218
-
219
- <slot
220
- name="item"
221
- :item="item"
222
- :header="itemHeaders"
223
- :index="i_body"
209
+ @click="emit('click-row', item, i_body)"
224
210
  >
225
- <ShadTableCell
226
- v-for="(header, i_header) in itemHeaders"
227
- :key="i_header"
228
- :class="
211
+ <!-- Checkbox per row -->
212
+ <ShadTableCell v-if="props.selectable" @click.stop>
213
+ <div class="flex items-center justify-center">
214
+ <InputCheckbox
215
+ :model-value="isSelected(item)"
216
+ @update:model-value="() => toggleRow(item)"
217
+ />
218
+ </div>
219
+ </ShadTableCell>
220
+
221
+ <slot
222
+ name="item"
223
+ :item="item"
224
+ :header="itemHeaders"
225
+ :index="i_body"
226
+ >
227
+ <ShadTableCell
228
+ v-for="(header, i_header) in itemHeaders"
229
+ :key="i_header"
230
+ :class="
229
231
  getAlignmentClass(header.alignment || props.cellAlignment)
230
232
  "
231
- :style="{
233
+ :style="{
232
234
  width: header.width ? typeof header.width === 'number' ? `${header.width}px` : header.width : void 0,
233
235
  minWidth: header.minWidth ? typeof header.minWidth === 'number' ? `${header.minWidth}px` : header.minWidth : void 0,
234
236
  maxWidth: header.maxWidth ? typeof header.maxWidth === 'number' ? `${header.maxWidth}px` : header.maxWidth : header.width ? typeof header.width === 'number' ? `${header.width}px` : header.width : header.minWidth ? typeof header.minWidth === 'number' ? `${header.minWidth}px` : header.minWidth : void 0
235
237
  }"
236
- >
237
- <slot :name="`item-${header.key}`" :item="item">
238
- {{ item[header.key] }}
239
- </slot>
240
- </ShadTableCell>
241
- </slot>
242
- </ShadTableRow>
238
+ >
239
+ <slot :name="`item-${header.key}`" :item="item">
240
+ {{ item[header.key] }}
241
+ </slot>
242
+ </ShadTableCell>
243
+ </slot>
244
+ </ShadTableRow>
245
+
246
+ <!-- แถวเสริมเต็มความกว้าง แทรกใต้แถวนี้ — ใช้กับกรณีเช่น banner "กำลังโอนสิทธิ์" (Figma 2779-278256) -->
247
+ <!-- ไม่ระบุ slot นี้ = ไม่มีอะไร render เพิ่ม, consumer เป็นคนคุม v-if ว่าจะโชว์กับ item ไหน -->
248
+ <slot
249
+ name="row-banner"
250
+ :item="item"
251
+ :index="i_body"
252
+ :colspan="totalColumns"
253
+ />
254
+ </template>
243
255
  </template>
244
256
 
245
257
  <!-- Empty State -->
@@ -34,7 +34,11 @@ declare var __VLS_13: {
34
34
  index: number;
35
35
  }, __VLS_106: `item-${string}`, __VLS_107: {
36
36
  item: Record<string, any>;
37
- }, __VLS_109: {}, __VLS_117: {};
37
+ }, __VLS_109: {
38
+ item: Record<string, any>;
39
+ index: number;
40
+ colspan: number;
41
+ }, __VLS_111: {}, __VLS_119: {};
38
42
  type __VLS_Slots = {} & {
39
43
  [K in NonNullable<typeof __VLS_41>]?: (props: typeof __VLS_42) => any;
40
44
  } & {
@@ -46,9 +50,11 @@ type __VLS_Slots = {} & {
46
50
  } & {
47
51
  item?: (props: typeof __VLS_97) => any;
48
52
  } & {
49
- empty?: (props: typeof __VLS_109) => any;
53
+ 'row-banner'?: (props: typeof __VLS_109) => any;
54
+ } & {
55
+ empty?: (props: typeof __VLS_111) => any;
50
56
  } & {
51
- 'empty-content'?: (props: typeof __VLS_117) => any;
57
+ 'empty-content'?: (props: typeof __VLS_119) => any;
52
58
  };
53
59
  declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
54
60
  "update:page": (value: number) => any;
@@ -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,260 @@
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
+ <div class="flex items-center justify-center w-[30px] h-[30px] rounded-full bg-muted shrink-0">
129
+ <Icon name="lucide:bell" size="16" class="text-primary" />
130
+ </div>
131
+ <div class="flex flex-col gap-[4px]">
132
+ <div class="font-body-medium">{{ item.message }}</div>
133
+ <div class="text-gray font-body-small">
134
+ {{ formatRelativeTime(item.created_at) }}
135
+ </div>
136
+ </div>
137
+ </div>
138
+ <div
139
+ v-if="!item.is_read"
140
+ class="bg-primary rounded-full w-[10px] h-[10px] shrink-0"
141
+ />
142
+ </div>
143
+ </Card>
144
+ </template>
145
+ </ShadTabsContent>
146
+ </ShadTabs>
147
+ </div>
148
+ </Drawer>
149
+ </template>
150
+
151
+ <script setup>
152
+ import { ref, computed, watch } from "vue";
153
+ import { useApi } from "../../composables/useApi";
154
+ import { useNotificationStore } from "../../composables/store-notification";
155
+ const api = useApi();
156
+ const modelValue = defineModel({ type: Boolean });
157
+ const notificationStore = useNotificationStore();
158
+ const emit = defineEmits(["select"]);
159
+ const selectedTab = ref("general");
160
+ const tabs = [
161
+ { value: "general", label: "\u0E17\u0E31\u0E48\u0E27\u0E44\u0E1B" },
162
+ { value: "system", label: "\u0E23\u0E30\u0E1A\u0E1A" }
163
+ ];
164
+ const generalNotifications = ref([]);
165
+ const systemNotifications = ref([]);
166
+ const isLoading = ref(false);
167
+ const notifications = computed(
168
+ () => selectedTab.value === "system" ? systemNotifications.value : generalNotifications.value
169
+ );
170
+ const hasUnreadGeneral = computed(
171
+ () => generalNotifications.value.some((n) => !n.is_read)
172
+ );
173
+ const hasUnreadSystem = computed(
174
+ () => systemNotifications.value.some((n) => !n.is_read)
175
+ );
176
+ const fetchNotifications = async () => {
177
+ try {
178
+ isLoading.value = true;
179
+ if (selectedTab.value === "system") {
180
+ const response = await api(`/notifications`, {
181
+ params: { page: 1, page_size: 50, category: "system" }
182
+ });
183
+ systemNotifications.value = response.data;
184
+ } else {
185
+ const response = await api(`/notifications`, {
186
+ params: { page: 1, page_size: 50 }
187
+ });
188
+ generalNotifications.value = response.data.filter(
189
+ (n) => n.category !== "system"
190
+ );
191
+ }
192
+ } catch (err) {
193
+ console.error("Fetch notifications error:", err);
194
+ } finally {
195
+ isLoading.value = false;
196
+ }
197
+ };
198
+ const markAsRead = async (item) => {
199
+ if (item.is_read) return;
200
+ try {
201
+ await api(`/notifications/${item.id}/read`, { method: "PATCH" });
202
+ item.is_read = true;
203
+ const totalUnread = generalNotifications.value.filter((n) => !n.is_read).length + systemNotifications.value.filter((n) => !n.is_read).length;
204
+ notificationStore.setUnreadCount(totalUnread);
205
+ } catch {
206
+ }
207
+ };
208
+ const markAllRead = async () => {
209
+ try {
210
+ await api(`/notifications/read-all`, { method: "PATCH" });
211
+ generalNotifications.value.forEach((n) => n.is_read = true);
212
+ systemNotifications.value.forEach((n) => n.is_read = true);
213
+ notificationStore.setUnreadCount(0);
214
+ } catch {
215
+ }
216
+ };
217
+ const onClickNotification = (item) => {
218
+ markAsRead(item);
219
+ emit("select", item);
220
+ };
221
+ const formatRelativeTime = (dateStr) => {
222
+ const now = /* @__PURE__ */ new Date();
223
+ const date = new Date(dateStr);
224
+ const diffMs = now.getTime() - date.getTime();
225
+ const diffSec = Math.floor(diffMs / 1e3);
226
+ const diffMin = Math.floor(diffSec / 60);
227
+ const diffHour = Math.floor(diffMin / 60);
228
+ const diffDay = Math.floor(diffHour / 24);
229
+ if (diffSec < 60) return "\u0E40\u0E21\u0E37\u0E48\u0E2D\u0E0B\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48";
230
+ if (diffMin < 60) return `${diffMin} \u0E19\u0E32\u0E17\u0E35\u0E17\u0E35\u0E48\u0E41\u0E25\u0E49\u0E27`;
231
+ if (diffHour < 24) return `${diffHour} \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E41\u0E25\u0E49\u0E27`;
232
+ if (diffDay < 7) return `${diffDay} \u0E27\u0E31\u0E19\u0E17\u0E35\u0E48\u0E41\u0E25\u0E49\u0E27`;
233
+ return date.toLocaleDateString("th-TH", {
234
+ day: "numeric",
235
+ month: "short",
236
+ year: "numeric"
237
+ });
238
+ };
239
+ watch(
240
+ () => notificationStore.isOpenDrawer,
241
+ (val) => {
242
+ if (val) {
243
+ fetchNotifications();
244
+ }
245
+ }
246
+ );
247
+ watch(selectedTab, () => {
248
+ if (notificationStore.isOpenDrawer) {
249
+ fetchNotifications();
250
+ }
251
+ });
252
+ watch(
253
+ () => notificationStore.unreadCount,
254
+ () => {
255
+ if (notificationStore.isOpenDrawer) {
256
+ fetchNotifications();
257
+ }
258
+ }
259
+ );
260
+ </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;
@@ -64,15 +64,15 @@ declare const __VLS_export: import("vue").DefineComponent<ImageCropperProps, {
64
64
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<ImageCropperProps> & Readonly<{}>, {
65
65
  src: string;
66
66
  center: boolean;
67
+ background: boolean;
68
+ modal: boolean;
67
69
  responsive: boolean;
68
70
  restore: boolean;
69
71
  checkCrossOrigin: boolean;
70
72
  checkOrientation: boolean;
71
73
  crossorigin: "" | "anonymous" | "use-credentials";
72
- modal: boolean;
73
74
  guides: boolean;
74
75
  highlight: boolean;
75
- background: boolean;
76
76
  autoCrop: boolean;
77
77
  movable: boolean;
78
78
  rotatable: boolean;
@@ -64,15 +64,15 @@ declare const __VLS_export: import("vue").DefineComponent<ImageCropperProps, {
64
64
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<ImageCropperProps> & Readonly<{}>, {
65
65
  src: string;
66
66
  center: boolean;
67
+ background: boolean;
68
+ modal: boolean;
67
69
  responsive: boolean;
68
70
  restore: boolean;
69
71
  checkCrossOrigin: boolean;
70
72
  checkOrientation: boolean;
71
73
  crossorigin: "" | "anonymous" | "use-credentials";
72
- modal: boolean;
73
74
  guides: boolean;
74
75
  highlight: boolean;
75
- background: boolean;
76
76
  autoCrop: boolean;
77
77
  movable: boolean;
78
78
  rotatable: boolean;
@@ -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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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" | "enter" | "go" | "previous" | "send" | undefined;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pukaad-ui-lib",
3
- "version": "1.345.0",
3
+ "version": "1.347.0",
4
4
  "description": "pukaad-ui for MeMSG",
5
5
  "repository": {
6
6
  "type": "git",