pukaad-ui-lib 1.421.0 → 1.423.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.421.0",
4
+ "version": "1.423.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -20,6 +20,7 @@ type __VLS_Props = {
20
20
  iconSize?: number | string;
21
21
  id?: string;
22
22
  state?: string;
23
+ loginPath?: string;
23
24
  };
24
25
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
25
26
  disabled: boolean;
@@ -32,6 +33,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {
32
33
  disabledDividerBottom: boolean;
33
34
  iconSize: number | string;
34
35
  state: string;
36
+ loginPath: string;
35
37
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
36
38
  declare const _default: typeof __VLS_export;
37
39
  export default _default;
@@ -32,8 +32,14 @@
32
32
 
33
33
  <script setup>
34
34
  import { computed, reactive, ref, watch } from "vue";
35
+ import { navigateTo, useCookie, useRoute, useRuntimeConfig } from "nuxt/app";
35
36
  import { useApi } from "../../composables/useApi";
36
37
  const api = useApi();
38
+ const route = useRoute();
39
+ const config = useRuntimeConfig();
40
+ const secId = useCookie(
41
+ config.public.APP_TYPE === "OFFICE" ? "OFFICE_SEC_ID" : "SEC_ID"
42
+ );
37
43
  const isOpenModalShare = ref(false);
38
44
  const props = defineProps({
39
45
  itemCount: { type: Object, required: false, default: () => ({
@@ -54,7 +60,8 @@ const props = defineProps({
54
60
  disabled: { type: Boolean, required: false, default: false },
55
61
  iconSize: { type: [Number, String], required: false, default: 20 },
56
62
  id: { type: String, required: false, default: "" },
57
- state: { type: String, required: false, default: "" }
63
+ state: { type: String, required: false, default: "" },
64
+ loginPath: { type: String, required: false, default: "/auth/login" }
58
65
  });
59
66
  const computedPadding = computed(
60
67
  () => props.disabledPadding ? 0 : props.padding
@@ -114,37 +121,46 @@ const reactions = [
114
121
  action: toggleShared
115
122
  }
116
123
  ];
124
+ function redirectToLogin() {
125
+ return navigateTo({
126
+ path: props.loginPath,
127
+ query: { redirect: route.fullPath }
128
+ });
129
+ }
130
+ function ensureLoggedIn() {
131
+ if (secId.value) return true;
132
+ redirectToLogin();
133
+ return false;
134
+ }
135
+ function isUnauthorized(error) {
136
+ const e = error;
137
+ return (e?.status ?? e?.statusCode) === 401;
138
+ }
117
139
  async function toggleLike() {
140
+ const likeUrl = props.state === "blog" && props.id ? `/blogs/${props.id}/like` : props.state === "content" && props.id ? `/contents/${props.id}/like` : "";
141
+ if (likeUrl && !ensureLoggedIn()) return;
118
142
  const previousLiked = reaction.liked;
119
143
  reaction.liked = !previousLiked;
120
144
  counts.liked += reaction.liked ? 1 : -1;
121
- if (props.state === "blog" && props.id) {
122
- try {
123
- await api(`/blogs/${props.id}/like`, {
124
- method: reaction.liked ? "POST" : "DELETE"
125
- });
126
- } catch (error) {
127
- console.error(error);
128
- reaction.liked = previousLiked;
129
- counts.liked += previousLiked ? 1 : -1;
130
- }
131
- } else if (props.state === "content" && props.id) {
132
- try {
133
- await api(`/contents/${props.id}/like`, {
134
- method: reaction.liked ? "POST" : "DELETE"
135
- });
136
- } catch (error) {
137
- console.error(error);
138
- reaction.liked = previousLiked;
139
- counts.liked += previousLiked ? 1 : -1;
140
- }
145
+ if (!likeUrl) return;
146
+ try {
147
+ await api(likeUrl, {
148
+ method: reaction.liked ? "POST" : "DELETE"
149
+ });
150
+ } catch (error) {
151
+ console.error(error);
152
+ reaction.liked = previousLiked;
153
+ counts.liked += previousLiked ? 1 : -1;
154
+ if (isUnauthorized(error)) redirectToLogin();
141
155
  }
142
156
  }
143
157
  async function toggleBookmark() {
158
+ const canCallApi = props.state === "content" && !!props.id;
159
+ if (canCallApi && !ensureLoggedIn()) return;
144
160
  const previousBookmarked = reaction.bookmarked;
145
161
  reaction.bookmarked = !previousBookmarked;
146
162
  counts.bookmarked += reaction.bookmarked ? 1 : -1;
147
- if (props.state === "content" && props.id) {
163
+ if (canCallApi) {
148
164
  try {
149
165
  await api(`/contents/${props.id}/bookmark`, {
150
166
  method: reaction.bookmarked ? "POST" : "DELETE"
@@ -153,6 +169,7 @@ async function toggleBookmark() {
153
169
  console.error(error);
154
170
  reaction.bookmarked = previousBookmarked;
155
171
  counts.bookmarked += previousBookmarked ? 1 : -1;
172
+ if (isUnauthorized(error)) redirectToLogin();
156
173
  }
157
174
  }
158
175
  }
@@ -20,6 +20,7 @@ type __VLS_Props = {
20
20
  iconSize?: number | string;
21
21
  id?: string;
22
22
  state?: string;
23
+ loginPath?: string;
23
24
  };
24
25
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
25
26
  disabled: boolean;
@@ -32,6 +33,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {
32
33
  disabledDividerBottom: boolean;
33
34
  iconSize: number | string;
34
35
  state: string;
36
+ loginPath: string;
35
37
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
36
38
  declare const _default: typeof __VLS_export;
37
39
  export default _default;
@@ -5,6 +5,7 @@
5
5
  <div class="flex flex-col gap-[2px] w-full">
6
6
  <div v-if="hasCoverFile" class="font-body-small-prominent text-gray">
7
7
  หน้าปก
8
+ <span class="text-destructive">*</span>
8
9
  </div>
9
10
  <!-- v-if ไม่ใช่ v-show — root ของ InputTextarea เป็น ShadFormField (renderless)
10
11
  style จาก v-show เลยตกไปไม่ถึง DOM จริง ซ่อนไม่ได้ -->
@@ -14,6 +15,7 @@
14
15
  name="cover-text"
15
16
  label="หน้าปก"
16
17
  class="w-full"
18
+ required
17
19
  placeholder="พิมพ์ข้อความหน้าปกหรืออัปโหลดไฟล์มีเดีย"
18
20
  disabledBorder
19
21
  resize="none"
@@ -101,16 +103,22 @@
101
103
  placeholder="อยากบอกอะไรเกี่ยวกับโพสต์นี้"
102
104
  disabledBorder
103
105
  resize="none"
106
+ required
104
107
  />
105
108
  <Divider height="1" />
106
109
  <InputTag
107
110
  v-model="selectedTags"
111
+ :profile-scope="props.state"
108
112
  label="เพิ่มแท็ก"
109
113
  placeholder="กรอกชื่อแท็ก"
110
114
  />
111
115
 
112
116
  <InputMention v-model:mentions="selectedMentions" />
113
- <InputSelectPlace v-model:places="selectedPlaces" />
117
+ <InputSelectPlace
118
+ v-model:places="selectedPlaces"
119
+ :profile-scope="props.state"
120
+ required
121
+ />
114
122
  </div>
115
123
  <div class="flex flex-col gap-[8px] items-center">
116
124
  <!-- cursor-default ทับ cursor-pointer ของการ์ด — พรีวิวคลิกไม่มีผล อย่าหลอกว่าเป็นปุ่ม
@@ -65,14 +65,14 @@ declare const __VLS_export: import("vue").DefineComponent<ImageCropperProps, {
65
65
  src: string;
66
66
  center: boolean;
67
67
  crossorigin: "" | "anonymous" | "use-credentials";
68
+ background: boolean;
69
+ modal: boolean;
68
70
  responsive: boolean;
69
71
  restore: boolean;
70
72
  checkCrossOrigin: boolean;
71
73
  checkOrientation: boolean;
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;
@@ -65,14 +65,14 @@ declare const __VLS_export: import("vue").DefineComponent<ImageCropperProps, {
65
65
  src: string;
66
66
  center: boolean;
67
67
  crossorigin: "" | "anonymous" | "use-credentials";
68
+ background: boolean;
69
+ modal: boolean;
68
70
  responsive: boolean;
69
71
  restore: boolean;
70
72
  checkCrossOrigin: boolean;
71
73
  checkOrientation: boolean;
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;
@@ -8,6 +8,8 @@ export interface SelectPlaceItem {
8
8
  interface InputSelectPlaceProps {
9
9
  label?: string;
10
10
  placeholder?: string;
11
+ required?: boolean;
12
+ profileScope?: "personal" | "business";
11
13
  }
12
14
  type __VLS_Props = InputSelectPlaceProps;
13
15
  type __VLS_ModelProps = {
@@ -20,7 +22,9 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
20
22
  "onUpdate:places"?: ((value: SelectPlaceItem[]) => any) | undefined;
21
23
  }>, {
22
24
  label: string;
25
+ required: boolean;
23
26
  placeholder: string;
27
+ profileScope: "personal" | "business";
24
28
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
25
29
  declare const _default: typeof __VLS_export;
26
30
  export default _default;
@@ -1,6 +1,9 @@
1
1
  <template>
2
2
  <div class="flex flex-col gap-[4px] w-full">
3
- <div class="font-body-large-prominent text-gray">{{ props.label }}</div>
3
+ <div class="font-body-large-prominent text-gray">
4
+ {{ props.label }}
5
+ <span v-if="props.required" class="text-destructive">*</span>
6
+ </div>
4
7
  <div v-show="selectedItems.length === 0">
5
8
  <InputCombobox
6
9
  ref="comboboxRef"
@@ -11,6 +14,12 @@
11
14
  command-list-class="max-h-[520px]"
12
15
  >
13
16
  <template #options="{ search }">
17
+ <ShadTabs v-model="currentTab" class="w-full p-2">
18
+ <ShadTabsList class="grid w-full grid-cols-2">
19
+ <ShadTabsTrigger value="popular">ยอดนิยม</ShadTabsTrigger>
20
+ <ShadTabsTrigger value="recent">ใช้งานล่าสุด</ShadTabsTrigger>
21
+ </ShadTabsList>
22
+ </ShadTabs>
14
23
  <template v-if="filterPlaces(search).length > 0">
15
24
  <div
16
25
  v-for="place in filterPlaces(search)"
@@ -56,7 +65,7 @@
56
65
  ที่กำหนดเองไม่ได้ผ่าน filter ของ cmdk เลยต้องกรอง+โชว์ empty state เองตรงนี้
57
66
  !isSearching กันโชว์ตอนกำลังยิง API อยู่ (ผลลัพธ์ยังว่างเพราะยังโหลดไม่เสร็จ ไม่ใช่ค้นไม่เจอจริง) -->
58
67
  <div
59
- v-else-if="!isSearching"
68
+ v-else-if="!isSearching && currentTab === 'popular'"
60
69
  class="flex gap-[8px] items-center hover:cursor-pointer"
61
70
  @click="drawerSuggestPlaceOpen = true"
62
71
  >
@@ -67,6 +76,22 @@
67
76
  </div>
68
77
  <div class="font-body-medium">เพิ่มสถานที่ที่ขาดไป</div>
69
78
  </div>
79
+ <div
80
+ v-else-if="!isSearching"
81
+ class="flex flex-col items-center justify-center gap-2 p-6 text-gray"
82
+ >
83
+ <Icon name="pukaad:tag-empty" class="h-[123px] w-[180px]" />
84
+ <div class="font-body-medium">
85
+ {{ search ? "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E2A\u0E16\u0E32\u0E19\u0E17\u0E35\u0E48" : "\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E16\u0E32\u0E19\u0E17\u0E35\u0E48\u0E17\u0E35\u0E48\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14" }}
86
+ </div>
87
+ </div>
88
+ <div v-if="isSearching" class="flex justify-center p-2">
89
+ <Icon
90
+ name="lucide:loader-2"
91
+ class="h-5 w-5 animate-spin text-primary"
92
+ />
93
+ </div>
94
+ <div ref="sentinelRef" class="h-1" />
70
95
  </template>
71
96
  </InputCombobox>
72
97
  </div>
@@ -127,18 +152,36 @@
127
152
 
128
153
  <script setup>
129
154
  import { computed, ref, watch } from "vue";
130
- import { useDebounceFn } from "@vueuse/core";
155
+ import { useDebounceFn, useIntersectionObserver } from "@vueuse/core";
156
+ import { useCookie } from "nuxt/app";
131
157
  import { useApi } from "../../composables/useApi";
132
158
  const props = defineProps({
133
159
  label: { type: String, required: false, default: "\u0E2A\u0E16\u0E32\u0E19\u0E17\u0E35\u0E48" },
134
- placeholder: { type: String, required: false, default: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2A\u0E16\u0E32\u0E19\u0E17\u0E35\u0E48" }
160
+ placeholder: { type: String, required: false, default: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2A\u0E16\u0E32\u0E19\u0E17\u0E35\u0E48" },
161
+ required: { type: Boolean, required: false, default: false },
162
+ profileScope: { type: String, required: false, default: "personal" }
135
163
  });
136
164
  const api = useApi();
165
+ const businessProfileId = useCookie(
166
+ "PUKAAD_BUSINESS_PROFILE_ID"
167
+ );
137
168
  const drawerSuggestPlaceOpen = ref(false);
138
169
  const comboboxRef = ref();
139
170
  const selectedIds = ref([]);
140
- const searchResults = ref([]);
141
- const isSearching = ref(false);
171
+ const currentTab = ref("popular");
172
+ const createListState = () => ({
173
+ items: [],
174
+ page: 1,
175
+ hasMore: true,
176
+ loading: false
177
+ });
178
+ const popularState = ref(createListState());
179
+ const recentState = ref(createListState());
180
+ const activeState = computed(
181
+ () => currentTab.value === "popular" ? popularState.value : recentState.value
182
+ );
183
+ const searchResults = computed(() => activeState.value.items);
184
+ const isSearching = computed(() => activeState.value.loading);
142
185
  const selectedCache = ref(/* @__PURE__ */ new Map());
143
186
  const mapPlace = (raw) => ({
144
187
  id: raw.id,
@@ -147,25 +190,42 @@ const mapPlace = (raw) => ({
147
190
  image: raw.cover_image,
148
191
  post_count: raw.post_count ?? 0
149
192
  });
150
- const performSearch = async (q) => {
151
- isSearching.value = true;
193
+ let requestGeneration = 0;
194
+ const fetchPlaces = async (type, q, reset = false) => {
195
+ const state = type === "popular" ? popularState.value : recentState.value;
196
+ if (reset) Object.assign(state, createListState());
197
+ if (state.loading || !state.hasMore) return;
198
+ state.loading = true;
199
+ const generation = requestGeneration;
152
200
  try {
153
- const res = await api(
154
- "/places/search",
155
- { query: { q, page: 1, page_size: 10 } }
156
- );
157
- searchResults.value = (res.data?.items ?? []).map(mapPlace);
201
+ const res = await api(type === "popular" ? "/places/search" : "/places/recent", {
202
+ query: {
203
+ q,
204
+ page: state.page,
205
+ page_size: 10,
206
+ scope: props.profileScope
207
+ }
208
+ });
209
+ if (generation !== requestGeneration) return;
210
+ const items = (res.data?.items ?? []).map(mapPlace);
211
+ state.items = [...state.items, ...items];
212
+ const pagination = res.data?.pagination;
213
+ state.page++;
214
+ state.hasMore = state.page <= (pagination?.total_page ?? 1);
158
215
  } catch {
159
- searchResults.value = [];
160
216
  } finally {
161
- isSearching.value = false;
217
+ if (generation === requestGeneration) state.loading = false;
162
218
  }
163
219
  };
164
- const debouncedSearch = useDebounceFn(performSearch, 300);
220
+ const debouncedSearch = useDebounceFn(
221
+ (q) => fetchPlaces(currentTab.value, q, true),
222
+ 300
223
+ );
165
224
  const searchTerm = ref("");
166
225
  watch(
167
- searchTerm,
168
- (val) => {
226
+ [searchTerm, currentTab],
227
+ ([val]) => {
228
+ requestGeneration++;
169
229
  debouncedSearch(val);
170
230
  },
171
231
  { immediate: true }
@@ -174,8 +234,17 @@ const filterPlaces = (search) => {
174
234
  if (searchTerm.value !== search) {
175
235
  searchTerm.value = search;
176
236
  }
177
- return searchResults.value;
237
+ return activeState.value.items;
178
238
  };
239
+ const sentinelRef = ref(null);
240
+ useIntersectionObserver(sentinelRef, ([entry]) => {
241
+ if (entry?.isIntersecting) fetchPlaces(currentTab.value, searchTerm.value);
242
+ });
243
+ watch([businessProfileId, () => props.profileScope], () => {
244
+ requestGeneration++;
245
+ recentState.value = createListState();
246
+ if (currentTab.value === "recent") fetchPlaces("recent", searchTerm.value);
247
+ });
179
248
  const isSelected = (id) => selectedIds.value.includes(id);
180
249
  const toggleSelect = (id) => {
181
250
  if (isSelected(id)) {
@@ -8,6 +8,8 @@ export interface SelectPlaceItem {
8
8
  interface InputSelectPlaceProps {
9
9
  label?: string;
10
10
  placeholder?: string;
11
+ required?: boolean;
12
+ profileScope?: "personal" | "business";
11
13
  }
12
14
  type __VLS_Props = InputSelectPlaceProps;
13
15
  type __VLS_ModelProps = {
@@ -20,7 +22,9 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
20
22
  "onUpdate:places"?: ((value: SelectPlaceItem[]) => any) | undefined;
21
23
  }>, {
22
24
  label: string;
25
+ required: boolean;
23
26
  placeholder: string;
27
+ profileScope: "personal" | "business";
24
28
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
25
29
  declare const _default: typeof __VLS_export;
26
30
  export default _default;
@@ -13,6 +13,7 @@ interface InputTagProps {
13
13
  placeholder?: string;
14
14
  state?: "user" | "admin";
15
15
  ignore?: string[];
16
+ profileScope?: "personal" | "business";
16
17
  }
17
18
  type __VLS_Props = InputTagProps;
18
19
  type __VLS_ModelProps = {
@@ -28,6 +29,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
28
29
  state: "user" | "admin";
29
30
  placeholder: string;
30
31
  limit: number;
32
+ profileScope: "personal" | "business";
31
33
  ignore: string[];
32
34
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
33
35
  declare const _default: typeof __VLS_export;
@@ -51,12 +51,15 @@
51
51
  <ShadCommand :filter="() => 1">
52
52
  <ShadCommandInput
53
53
  v-if="props.state === 'admin'"
54
+ v-model="searchQuery"
54
55
  placeholder="ค้นหาแท็ก"
55
56
  />
56
57
  <ShadTabs v-model="currentTab" class="w-full p-2">
57
58
  <ShadTabsList class="w-full grid grid-cols-2">
58
59
  <ShadTabsTrigger value="popular">ยอดนิยม</ShadTabsTrigger>
59
- <ShadTabsTrigger value="latest">ล่าสุด</ShadTabsTrigger>
60
+ <ShadTabsTrigger value="recent">{{
61
+ isOffice ? "\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14" : "\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14"
62
+ }}</ShadTabsTrigger>
60
63
  </ShadTabsList>
61
64
 
62
65
  <!-- Popular Tab -->
@@ -87,7 +90,9 @@
87
90
  class="flex flex-col items-center justify-center h-full gap-2"
88
91
  >
89
92
  <Icon name="pukaad:tag-empty" class="h-[123px] w-[180px]" />
90
- <div class="text-gray font-body-medium">ไม่พบข้อมูล</div>
93
+ <div class="text-gray font-body-medium">
94
+ {{ popularState.error || "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25" }}
95
+ </div>
91
96
  </div>
92
97
  </ShadCommandEmpty>
93
98
 
@@ -104,12 +109,12 @@
104
109
  </ShadCommandList>
105
110
  </ShadTabsContent>
106
111
 
107
- <!-- Latest Tab -->
108
- <ShadTabsContent value="latest" class="m-0">
112
+ <!-- Recent / Office latest tab -->
113
+ <ShadTabsContent value="recent" class="m-0">
109
114
  <ShadCommandList class="max-h-64 overflow-y-auto">
110
115
  <ShadCommandGroup>
111
116
  <ShadCommandItem
112
- v-for="tag in filteredLatestTags"
117
+ v-for="tag in filteredRecentTags"
113
118
  :key="tag.value"
114
119
  :value="tag.value"
115
120
  class="flex w-full justify-between"
@@ -125,19 +130,23 @@
125
130
  <!-- Initital Loading / Empty State -->
126
131
  <ShadCommandEmpty
127
132
  :force-show="
128
- filteredLatestTags.length === 0 && !latestState.loading
133
+ filteredRecentTags.length === 0 && !recentState.loading
129
134
  "
130
135
  >
131
136
  <div
132
137
  class="flex flex-col items-center justify-center h-full gap-2"
133
138
  >
134
139
  <Icon name="pukaad:tag-empty" class="h-[123px] w-[180px]" />
135
- <div class="text-gray font-body-medium">ไม่พบข้อมูล</div>
140
+ <div class="text-gray font-body-medium">
141
+ {{
142
+ recentState.error || (!isOffice && !searchQuery && recentState.data.length === 0 ? "\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E41\u0E17\u0E47\u0E01\u0E17\u0E35\u0E48\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14" : "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25")
143
+ }}
144
+ </div>
136
145
  </div>
137
146
  </ShadCommandEmpty>
138
147
 
139
148
  <!-- Loading Indicator -->
140
- <div v-if="latestState.loading" class="flex justify-center p-2">
149
+ <div v-if="recentState.loading" class="flex justify-center p-2">
141
150
  <Icon
142
151
  name="lucide:loader-2"
143
152
  class="animate-spin h-5 w-5 text-primary"
@@ -145,7 +154,7 @@
145
154
  </div>
146
155
 
147
156
  <!-- Sentinel for Infinite Scroll -->
148
- <div ref="latestSentinel" class="h-1" />
157
+ <div ref="recentSentinel" class="h-1" />
149
158
  </ShadCommandList>
150
159
  </ShadTabsContent>
151
160
  </ShadTabs>
@@ -155,13 +164,21 @@
155
164
  </template>
156
165
 
157
166
  <script setup>
158
- import { ref, computed, onMounted, reactive, watch } from "vue";
159
- import { useNuxtApp } from "nuxt/app";
167
+ import { ref, computed, reactive, watch } from "vue";
168
+ import { useNuxtApp, useRuntimeConfig, useCookie } from "nuxt/app";
160
169
  import { useApi } from "../../composables/useApi";
161
170
  import { useIntersectionObserver } from "@vueuse/core";
162
171
  const { $convert } = useNuxtApp();
163
172
  const convertNumber = (val) => $convert?.convertNumber(val) || val;
164
173
  const api = useApi();
174
+ const config = useRuntimeConfig();
175
+ const isOffice = computed(
176
+ () => props.state === "admin" || config.public.APP_TYPE === "OFFICE"
177
+ );
178
+ const businessProfileId = useCookie(
179
+ "PUKAAD_BUSINESS_PROFILE_ID"
180
+ );
181
+ const sessionId = useCookie("SEC_ID");
165
182
  const props = defineProps({
166
183
  name: { type: String, required: false, default: "input-tag" },
167
184
  rules: { type: [Object, String, Function], required: false },
@@ -171,7 +188,8 @@ const props = defineProps({
171
188
  limit: { type: Number, required: false, default: 0 },
172
189
  placeholder: { type: String, required: false, default: "\u0E04\u0E49\u0E19\u0E2B\u0E32\u0E41\u0E17\u0E47\u0E01..." },
173
190
  state: { type: String, required: false, default: "user" },
174
- ignore: { type: Array, required: false, default: () => [] }
191
+ ignore: { type: Array, required: false, default: () => [] },
192
+ profileScope: { type: String, required: false, default: "personal" }
175
193
  });
176
194
  const modelValue = defineModel({ type: Array, ...{
177
195
  default: () => []
@@ -185,13 +203,15 @@ const popularState = reactive({
185
203
  data: [],
186
204
  page: 1,
187
205
  hasMore: true,
188
- loading: false
206
+ loading: false,
207
+ error: ""
189
208
  });
190
- const latestState = reactive({
209
+ const recentState = reactive({
191
210
  data: [],
192
211
  page: 1,
193
212
  hasMore: true,
194
- loading: false
213
+ loading: false,
214
+ error: ""
195
215
  });
196
216
  const mapTag = (tag) => ({
197
217
  label: tag.name,
@@ -199,14 +219,23 @@ const mapTag = (tag) => ({
199
219
  postCount: tag.usage_count
200
220
  });
201
221
  const PAGE_SIZE = 20;
222
+ let requestGeneration = 0;
202
223
  const fetchTags = async (type) => {
203
- const state = type === "popular" ? popularState : latestState;
224
+ const state = type === "popular" ? popularState : recentState;
204
225
  if (state.loading || !state.hasMore) return;
205
226
  state.loading = true;
227
+ state.error = "";
228
+ const generation = requestGeneration;
206
229
  try {
207
- const res = await api(
208
- `/tags/${type}?page=${state.page}&page_size=${PAGE_SIZE}`
209
- );
230
+ const endpoint = type === "recent" && isOffice.value ? "latest" : type;
231
+ const res = await api(`/tags/${endpoint}`, {
232
+ query: {
233
+ page: state.page,
234
+ page_size: PAGE_SIZE,
235
+ scope: props.profileScope
236
+ }
237
+ });
238
+ if (generation !== requestGeneration) return;
210
239
  if (res.data && Array.isArray(res.data)) {
211
240
  const newTags = res.data.map(mapTag);
212
241
  state.data = [...state.data, ...newTags];
@@ -216,42 +245,65 @@ const fetchTags = async (type) => {
216
245
  state.hasMore = false;
217
246
  }
218
247
  } catch (e) {
248
+ if (generation !== requestGeneration) return;
219
249
  console.error(`Failed to fetch ${type} tags:`, e);
220
- state.hasMore = false;
250
+ state.error = "\u0E42\u0E2B\u0E25\u0E14\u0E41\u0E17\u0E47\u0E01\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E1B\u0E34\u0E14\u0E41\u0E25\u0E49\u0E27\u0E40\u0E1B\u0E34\u0E14\u0E43\u0E2B\u0E21\u0E48";
221
251
  } finally {
222
- state.loading = false;
252
+ if (generation === requestGeneration) state.loading = false;
223
253
  }
224
254
  };
225
255
  watch(popoverOpen, (isOpen) => {
226
256
  if (isOpen) {
257
+ resetLists();
227
258
  if (currentTab.value === "popular" && popularState.data.length === 0) {
228
259
  fetchTags("popular");
229
- } else if (currentTab.value === "latest" && latestState.data.length === 0) {
230
- fetchTags("latest");
260
+ } else if (currentTab.value === "recent" && recentState.data.length === 0) {
261
+ fetchTags("recent");
231
262
  }
232
263
  }
233
264
  });
234
265
  const popularSentinel = ref(null);
235
- const latestSentinel = ref(null);
266
+ const recentSentinel = ref(null);
236
267
  useIntersectionObserver(popularSentinel, (entries) => {
237
268
  const entry = entries[0];
238
269
  if (entry?.isIntersecting && currentTab.value === "popular" && popoverOpen.value) {
239
270
  fetchTags("popular");
240
271
  }
241
272
  });
242
- useIntersectionObserver(latestSentinel, (entries) => {
273
+ useIntersectionObserver(recentSentinel, (entries) => {
243
274
  const entry = entries[0];
244
- if (entry?.isIntersecting && currentTab.value === "latest" && popoverOpen.value) {
245
- fetchTags("latest");
275
+ if (entry?.isIntersecting && currentTab.value === "recent" && popoverOpen.value) {
276
+ fetchTags("recent");
246
277
  }
247
278
  });
248
279
  watch(currentTab, (newTab) => {
280
+ if (!popoverOpen.value) return;
249
281
  if (newTab === "popular" && popularState.data.length === 0) {
250
282
  fetchTags("popular");
251
- } else if (newTab === "latest" && latestState.data.length === 0) {
252
- fetchTags("latest");
283
+ } else if (newTab === "recent" && recentState.data.length === 0) {
284
+ fetchTags("recent");
253
285
  }
254
286
  });
287
+ const resetLists = () => {
288
+ requestGeneration++;
289
+ for (const state of [popularState, recentState]) {
290
+ Object.assign(state, {
291
+ data: [],
292
+ page: 1,
293
+ hasMore: true,
294
+ loading: false,
295
+ error: ""
296
+ });
297
+ }
298
+ };
299
+ watch(
300
+ [sessionId, businessProfileId, () => props.profileScope, isOffice],
301
+ () => {
302
+ resetLists();
303
+ if (popoverOpen.value)
304
+ fetchTags(currentTab.value === "popular" ? "popular" : "recent");
305
+ }
306
+ );
255
307
  const selectedTags = computed(() => modelValue.value);
256
308
  const isLimitReached = computed(() => {
257
309
  if (props.limit <= 0) return false;
@@ -286,7 +338,7 @@ const filterTags = (tags) => {
286
338
  );
287
339
  };
288
340
  const filteredPopularTags = computed(() => filterTags(popularState.data));
289
- const filteredLatestTags = computed(() => filterTags(latestState.data));
341
+ const filteredRecentTags = computed(() => filterTags(recentState.data));
290
342
  const onSelectTag = (tag) => {
291
343
  if (isSelected(tag.value)) {
292
344
  modelValue.value = modelValue.value.filter((t) => t.id !== tag.value);
@@ -318,7 +370,7 @@ const onAddCustomTag = () => {
318
370
  return;
319
371
  }
320
372
  const newValue = trimmedQuery.toLowerCase().replace(/\s+/g, "-");
321
- const existingTag = [...popularState.data, ...latestState.data].find(
373
+ const existingTag = [...popularState.data, ...recentState.data].find(
322
374
  (tag) => tag.value === newValue || tag.label === trimmedQuery
323
375
  );
324
376
  if (existingTag) {
@@ -13,6 +13,7 @@ interface InputTagProps {
13
13
  placeholder?: string;
14
14
  state?: "user" | "admin";
15
15
  ignore?: string[];
16
+ profileScope?: "personal" | "business";
16
17
  }
17
18
  type __VLS_Props = InputTagProps;
18
19
  type __VLS_ModelProps = {
@@ -28,6 +29,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
28
29
  state: "user" | "admin";
29
30
  placeholder: string;
30
31
  limit: number;
32
+ profileScope: "personal" | "business";
31
33
  ignore: string[];
32
34
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
33
35
  declare const _default: typeof __VLS_export;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pukaad-ui-lib",
3
- "version": "1.421.0",
3
+ "version": "1.423.0",
4
4
  "description": "pukaad-ui for MeMSG",
5
5
  "repository": {
6
6
  "type": "git",