pukaad-ui-lib 1.351.0 → 1.352.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.351.0",
4
+ "version": "1.352.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,10 +1,14 @@
1
- import type { CardReviewProps } from "#pukaad-ui/types/components/card/card-review";
1
+ import type { CardReviewProps, Replies } from "#pukaad-ui/types/components/card/card-review";
2
2
  type __VLS_Props = {
3
3
  item: CardReviewProps;
4
4
  isProfile: boolean;
5
5
  disabledLike?: boolean;
6
6
  disabledMenu?: boolean;
7
- /** ถ้ามี place context like/unlike และ edit จัดการใน component เลย */
7
+ /** เปิดให้ตอบกลับรีวิวได้ (สำหรับ business เจ้าของสถานที่เท่านั้น) เมนู "ตอบกลับ" จะโผล่ในปุ่ม */
8
+ allowReply?: boolean;
9
+ /** avatar ของ business ที่ใช้แสดงในฟอร์มตอบกลับ (Figma 2456:53296) */
10
+ replyAvatar?: string;
11
+ /** ถ้ามี place context → like/unlike, edit และ reply จัดการใน component เลย */
8
12
  place?: {
9
13
  id: string | number;
10
14
  name?: string;
@@ -18,17 +22,25 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {
18
22
  "toggle-like": (item: CardReviewProps, liked: boolean) => any;
19
23
  "select-image": (item: CardReviewProps, index: number) => any;
20
24
  "review-updated": (data: unknown) => any;
25
+ "reply-updated": (item: CardReviewProps, reply?: Replies | undefined) => any;
26
+ "reply-submit": (item: CardReviewProps, description: string) => any;
27
+ "reply-delete": (item: CardReviewProps) => any;
21
28
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
22
29
  onDelete?: ((item: CardReviewProps) => any) | undefined;
23
30
  onEdit?: ((item: CardReviewProps) => any) | undefined;
24
31
  "onToggle-like"?: ((item: CardReviewProps, liked: boolean) => any) | undefined;
25
32
  "onSelect-image"?: ((item: CardReviewProps, index: number) => any) | undefined;
26
33
  "onReview-updated"?: ((data: unknown) => any) | undefined;
34
+ "onReply-updated"?: ((item: CardReviewProps, reply?: Replies | undefined) => any) | undefined;
35
+ "onReply-submit"?: ((item: CardReviewProps, description: string) => any) | undefined;
36
+ "onReply-delete"?: ((item: CardReviewProps) => any) | undefined;
27
37
  }>, {
28
38
  item: CardReviewProps;
29
39
  disabledMenu: boolean;
30
40
  isProfile: boolean;
31
41
  disabledLike: boolean;
42
+ allowReply: boolean;
43
+ replyAvatar: string;
32
44
  place: {
33
45
  id: string | number;
34
46
  name?: string;
@@ -44,6 +44,7 @@
44
44
  disabled-padding
45
45
  @review-edit="onEdit"
46
46
  @review-delete="emit('delete', props.item)"
47
+ @review-reply="openReplyForm"
47
48
  />
48
49
  </div>
49
50
  </div>
@@ -78,16 +79,46 @@
78
79
  </div>
79
80
  </div>
80
81
 
81
- <div v-if="props.item.replies">
82
- <div class="p-[16px] rounded-sm bg-bright">
82
+ <div
83
+ v-if="localReplies && !isReplyOpen"
84
+ class="relative p-[16px] rounded-md bg-bright flex flex-col gap-[4px]"
85
+ >
86
+ <!-- หางบอลลูนชี้ขึ้นบน (Figma 2456:53435 Polygon 8) -->
87
+ <div
88
+ class="absolute -top-[9px] left-[8px] w-0 h-0 border-l-[9px] border-l-transparent border-r-[9px] border-r-transparent border-b-[12px] border-b-bright"
89
+ />
90
+ <div class="flex justify-between items-center">
83
91
  <div class="text-gray font-body-large">
84
- การตอบกลับจาก {{ props.item.replies?.author }}
85
- </div>
86
- <div class="font-body-large">
87
- {{ props.item.replies?.description }}
92
+ การตอบกลับจาก {{ localReplies.author }}
88
93
  </div>
94
+ <!-- เมนูจัดการคำตอบกลับของตัวเอง — เฉพาะ business (Figma 2456:88751) -->
95
+ <PickerOptionMenuUser
96
+ v-if="props.allowReply"
97
+ state="my-reply"
98
+ disabled-padding
99
+ @reply-edit="openReplyForm"
100
+ @reply-delete="emit('reply-delete', props.item)"
101
+ />
102
+ </div>
103
+ <!-- whitespace-pre-wrap ให้ \n จาก Shift+Enter แสดงจริง (เทียบ comment.vue) -->
104
+ <div class="font-body-large whitespace-pre-wrap break-words">
105
+ {{ normalizeContent(localReplies.description) }}
89
106
  </div>
90
107
  </div>
108
+
109
+ <!-- ฟอร์มตอบกลับ (business เท่านั้น) — Figma 2456:53296/53525
110
+ ไม่ส่ง reply-to → ชื่อคนอยู่ใน placeholder เฉยๆ ไม่ปนลงไปในข้อความที่ส่ง -->
111
+ <InputComment
112
+ v-if="isReplyOpen"
113
+ v-model="replyText"
114
+ :avatar="props.replyAvatar"
115
+ :placeholder="`\u0E15\u0E2D\u0E1A\u0E01\u0E25\u0E31\u0E1A ${props.item.user?.name ?? ''}`"
116
+ :loading="isSubmittingReply"
117
+ submit-text="ตอบกลับ"
118
+ autofocus
119
+ @submit="submitReply"
120
+ @cancel="isReplyOpen = false"
121
+ />
91
122
  </div>
92
123
  </div>
93
124
 
@@ -148,9 +179,11 @@ const props = defineProps({
148
179
  isProfile: { type: Boolean, required: true, default: false },
149
180
  disabledLike: { type: Boolean, required: false, default: false },
150
181
  disabledMenu: { type: Boolean, required: false, default: false },
182
+ allowReply: { type: Boolean, required: false, default: false },
183
+ replyAvatar: { type: String, required: false, default: "" },
151
184
  place: { type: Object, required: false, default: void 0 }
152
185
  });
153
- const emit = defineEmits(["toggle-like", "select-image", "edit", "delete", "review-updated"]);
186
+ const emit = defineEmits(["toggle-like", "select-image", "edit", "delete", "review-updated", "reply-updated", "reply-submit", "reply-delete"]);
154
187
  const isOpen = ref(false);
155
188
  const startIndex = ref(0);
156
189
  const liked = ref(props.item.review?.liked ?? false);
@@ -178,9 +211,11 @@ const drawerItem = computed(() => ({
178
211
  description: props.item.review?.description ?? "",
179
212
  photos: (props.item.review?.images ?? []).map((url) => ({ url }))
180
213
  }));
181
- const menuType = computed(
182
- () => props.item.user?.is_my_review ? "my-review" : "report-review"
183
- );
214
+ const menuType = computed(() => {
215
+ if (props.item.user?.is_my_review) return "my-review";
216
+ if (props.allowReply && !localReplies.value) return "business-review";
217
+ return "report-review";
218
+ });
184
219
  const navigateToProfile = (pathName) => {
185
220
  if (!pathName) return;
186
221
  router.push(`/@${pathName}`);
@@ -221,6 +256,56 @@ const onEdit = () => {
221
256
  const onReviewSuccess = (data) => {
222
257
  emit("review-updated", data);
223
258
  };
259
+ const localReplies = ref(props.item.replies);
260
+ const isReplyOpen = ref(false);
261
+ const replyText = ref("");
262
+ const isSubmittingReply = ref(false);
263
+ const normalizeContent = (text) => text.replace(/\n{3,}/g, "\n\n");
264
+ watch(
265
+ () => props.item.replies,
266
+ (val) => {
267
+ localReplies.value = val;
268
+ }
269
+ );
270
+ const openReplyForm = () => {
271
+ replyText.value = localReplies.value?.description ?? "";
272
+ isReplyOpen.value = true;
273
+ };
274
+ const submitReply = async () => {
275
+ const description = replyText.value.trim();
276
+ if (!description || isSubmittingReply.value) return;
277
+ if (!props.place?.id) {
278
+ emit("reply-submit", props.item, description);
279
+ isReplyOpen.value = false;
280
+ return;
281
+ }
282
+ isSubmittingReply.value = true;
283
+ try {
284
+ const isEdit = !!localReplies.value;
285
+ const res = await api(
286
+ `/suggest-places/${props.place.id}/reviews/${props.item.review_id}/reply`,
287
+ {
288
+ method: isEdit ? "patch" : "post",
289
+ body: { description }
290
+ }
291
+ );
292
+ localReplies.value = res?.data ?? {
293
+ replies_id: localReplies.value?.replies_id ?? 0,
294
+ author: localReplies.value?.author ?? "",
295
+ description,
296
+ created_at: localReplies.value?.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
297
+ };
298
+ emit("reply-updated", props.item, localReplies.value);
299
+ isReplyOpen.value = false;
300
+ $toast.success(
301
+ isEdit ? "\u0E41\u0E01\u0E49\u0E44\u0E02\u0E01\u0E32\u0E23\u0E15\u0E2D\u0E1A\u0E01\u0E25\u0E31\u0E1A\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 !" : "\u0E15\u0E2D\u0E1A\u0E01\u0E25\u0E31\u0E1A\u0E23\u0E35\u0E27\u0E34\u0E27\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 !"
302
+ );
303
+ } catch {
304
+ $toast.error("\u0E40\u0E01\u0E34\u0E14\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07");
305
+ } finally {
306
+ isSubmittingReply.value = false;
307
+ }
308
+ };
224
309
  const selectImage = (index) => {
225
310
  startIndex.value = index;
226
311
  isOpen.value = true;
@@ -1,10 +1,14 @@
1
- import type { CardReviewProps } from "#pukaad-ui/types/components/card/card-review";
1
+ import type { CardReviewProps, Replies } from "#pukaad-ui/types/components/card/card-review";
2
2
  type __VLS_Props = {
3
3
  item: CardReviewProps;
4
4
  isProfile: boolean;
5
5
  disabledLike?: boolean;
6
6
  disabledMenu?: boolean;
7
- /** ถ้ามี place context like/unlike และ edit จัดการใน component เลย */
7
+ /** เปิดให้ตอบกลับรีวิวได้ (สำหรับ business เจ้าของสถานที่เท่านั้น) เมนู "ตอบกลับ" จะโผล่ในปุ่ม */
8
+ allowReply?: boolean;
9
+ /** avatar ของ business ที่ใช้แสดงในฟอร์มตอบกลับ (Figma 2456:53296) */
10
+ replyAvatar?: string;
11
+ /** ถ้ามี place context → like/unlike, edit และ reply จัดการใน component เลย */
8
12
  place?: {
9
13
  id: string | number;
10
14
  name?: string;
@@ -18,17 +22,25 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {
18
22
  "toggle-like": (item: CardReviewProps, liked: boolean) => any;
19
23
  "select-image": (item: CardReviewProps, index: number) => any;
20
24
  "review-updated": (data: unknown) => any;
25
+ "reply-updated": (item: CardReviewProps, reply?: Replies | undefined) => any;
26
+ "reply-submit": (item: CardReviewProps, description: string) => any;
27
+ "reply-delete": (item: CardReviewProps) => any;
21
28
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
22
29
  onDelete?: ((item: CardReviewProps) => any) | undefined;
23
30
  onEdit?: ((item: CardReviewProps) => any) | undefined;
24
31
  "onToggle-like"?: ((item: CardReviewProps, liked: boolean) => any) | undefined;
25
32
  "onSelect-image"?: ((item: CardReviewProps, index: number) => any) | undefined;
26
33
  "onReview-updated"?: ((data: unknown) => any) | undefined;
34
+ "onReply-updated"?: ((item: CardReviewProps, reply?: Replies | undefined) => any) | undefined;
35
+ "onReply-submit"?: ((item: CardReviewProps, description: string) => any) | undefined;
36
+ "onReply-delete"?: ((item: CardReviewProps) => any) | undefined;
27
37
  }>, {
28
38
  item: CardReviewProps;
29
39
  disabledMenu: boolean;
30
40
  isProfile: boolean;
31
41
  disabledLike: boolean;
42
+ allowReply: boolean;
43
+ replyAvatar: string;
32
44
  place: {
33
45
  id: string | number;
34
46
  name?: string;
@@ -7,43 +7,17 @@
7
7
  </div>
8
8
  </div>
9
9
 
10
- <div class="flex flex-col gap-[16px]">
11
- <div class="flex gap-[8px] w-full">
12
- <Avatar :size="30" :src="currentUser.avatar" />
13
- <div ref="mainInputRef" contenteditable="true"
14
- class="w-full min-h-[36px] px-[12px] py-[5px] rounded-md border border-mercury font-body-large focus:outline-none focus:ring-2 focus:ring-primary break-words bg-white empty:before:content-[attr(data-placeholder)] empty:before:text-gray"
15
- data-placeholder="เพิ่มความคิดเห็น" @focus="isShowMainActions = true" @input="onMainCommentInput"
16
- @keydown.enter.exact.prevent="onSendComment" @keydown.shift.enter.prevent="onInsertLineBreak" />
17
- </div>
18
- <div v-if="isShowMainActions" class="flex justify-end gap-[8px]">
19
- <Button variant="outline" class="w-[56px]" @click="onCancelComment">
20
- ยกเลิก
21
- </Button>
22
- <Button color="primary" class="w-[56px]" :disabled="!mainCommentInput" @click="onSendComment">
23
- ส่ง
24
- </Button>
25
- </div>
26
- </div>
10
+ <!-- กล่องคอมเมนต์หลัก — ปุ่มโผล่ตอนคลิกเข้าช่องพิมพ์ (actions-on-focus) -->
11
+ <InputComment ref="mainInputRef" v-model="mainCommentInput" :avatar="currentUser.avatar"
12
+ placeholder="เพิ่มความคิดเห็น" actions-on-focus @submit="onSendComment" />
27
13
  <div v-for="cmt in resolvedComments" :key="cmt.id" class="flex flex-col gap-[16px] w-full">
28
14
  <div class="flex flex-col gap-[4px] w-full">
29
15
  <div class="flex gap-[8px]" @mouseenter="hoveredId = cmt.id" @mouseleave="hoveredId = null">
30
16
  <Avatar :size="30" :src="cmt.user.avatar" class="cursor-pointer" @click="onViewProfileComment(cmt.user.id)" />
31
17
  <template v-if="editingId === cmt.id">
32
- <div class="flex-1 flex flex-col gap-[4px]">
33
- <div :ref="(el) => setEditInputRef(el, cmt.id)" contenteditable="true"
34
- class="w-full min-h-[36px] px-[12px] py-[5px] rounded-md border border-mercury font-body-large focus:outline-none focus:ring-2 focus:ring-primary break-words bg-white"
35
- @input="onEditInput($event, cmt.id)" @keydown.enter.exact.prevent="onSendEdit(cmt, 'comment')"
36
- @keydown.shift.enter.prevent="onInsertLineBreak" />
37
- <div class="flex justify-end gap-[8px]">
38
- <Button variant="outline" class="w-[56px]" @click="onCancelEdit">
39
- ยกเลิก
40
- </Button>
41
- <Button color="primary" class="w-[56px]" :disabled="!editTextMap[cmt.id]"
42
- @click="onSendEdit(cmt, 'comment')">
43
- ส่ง
44
- </Button>
45
- </div>
46
- </div>
18
+ <!-- กล่องแก้ไข อวาตาร์ใช้ของแถวข้างนอก เลย disabled-avatar -->
19
+ <InputComment v-model="editText" disabled-avatar autofocus class="flex-1 min-w-0"
20
+ @submit="onSendEdit(cmt, 'comment')" @cancel="onCancelEdit" />
47
21
  </template>
48
22
  <template v-else>
49
23
  <div class="flex flex-col gap-[4px] bg-bright p-[8px] rounded-lg">
@@ -105,25 +79,10 @@
105
79
  </Button>
106
80
  </div>
107
81
  </div>
108
- <div v-if="replyingToId === cmt.id" class="flex flex-col gap-[16px]">
109
- <div class="flex gap-[8px] w-full">
110
- <Avatar :size="30" :src="currentUser.avatar" />
111
- <div class="flex-1">
112
- <div :ref="(el) => setReplyInputRef(el, cmt.id)" contenteditable="true"
113
- class="w-full min-h-[36px] px-[12px] py-[5px] rounded-md border border-mercury font-body-large focus:outline-none focus:ring-2 focus:ring-primary break-words bg-white empty:before:content-[attr(data-placeholder)] empty:before:text-gray"
114
- data-placeholder="เขียนการตอบกลับ..." @input="onReplyInput($event, cmt.id)"
115
- @keydown.enter.exact.prevent="onSendReplyComment(cmt)" @keydown.shift.enter.prevent="onInsertLineBreak" />
116
- </div>
117
- </div>
118
- <div class="flex justify-end gap-[8px]">
119
- <Button variant="outline" class="w-[56px]" @click="onCancelReplyComment">
120
- ยกเลิก
121
- </Button>
122
- <Button color="primary" class="w-[68px]" :disabled="!replyTextMap[cmt.id]" @click="onSendReplyComment(cmt)">
123
- ตอบกลับ
124
- </Button>
125
- </div>
126
- </div>
82
+ <!-- กล่องตอบกลับคอมเมนต์ ชิป mention ผ่าน v-model:reply-to -->
83
+ <InputComment v-if="replyingToId === cmt.id" v-model="replyText" v-model:reply-to="replyToUser"
84
+ :avatar="currentUser.avatar" placeholder="เขียนการตอบกลับ..." submit-text="ตอบกลับ" autofocus
85
+ @submit="onSendReplyComment(cmt)" @cancel="onCancelReplyComment" />
127
86
  <div v-if="cmt.reply.length > 0" v-show="
128
87
  openedComments.includes(cmt.id) || cmt.reply.some((r) => pinnedReplyIds.has(r.id))
129
88
  " class="pl-[38px] flex flex-col gap-[16px]">
@@ -134,21 +93,9 @@
134
93
  <Avatar :size="30" :src="reply.user.avatar" class="cursor-pointer"
135
94
  @click="onViewProfileComment(reply.user.id)" />
136
95
  <template v-if="editingId === reply.id">
137
- <div class="flex-1 flex flex-col gap-[4px]">
138
- <div :ref="(el) => setEditInputRef(el, reply.id)" contenteditable="true"
139
- class="w-full min-h-[36px] px-[12px] py-[5px] rounded-md border border-mercury font-body-large focus:outline-none focus:ring-2 focus:ring-primary break-words bg-white"
140
- @input="onEditInput($event, reply.id)" @keydown.enter.exact.prevent="onSendEdit(reply, 'reply')"
141
- @keydown.shift.enter.prevent="onInsertLineBreak" />
142
- <div class="flex justify-end gap-[8px]">
143
- <Button variant="outline" class="w-[56px]" @click="onCancelEdit">
144
- ยกเลิก
145
- </Button>
146
- <Button color="primary" class="w-[56px]" :disabled="!editTextMap[reply.id]"
147
- @click="onSendEdit(reply, 'reply')">
148
- ส่ง
149
- </Button>
150
- </div>
151
- </div>
96
+ <!-- แก้ไข reply — ชิป reply_to โชว์ผ่าน v-model:reply-to (ข้อความที่ส่งไม่รวมชื่อ) -->
97
+ <InputComment v-model="editText" v-model:reply-to="editReplyTo" disabled-avatar autofocus
98
+ class="flex-1 min-w-0" @submit="onSendEdit(reply, 'reply')" @cancel="onCancelEdit" />
152
99
  </template>
153
100
  <template v-else>
154
101
  <div class="flex flex-col bg-bright p-[8px] rounded-lg gap-[4px]">
@@ -199,27 +146,10 @@
199
146
  </div>
200
147
  </div>
201
148
  </div>
202
- <div v-if="replyingToId === reply.id" class="flex flex-col gap-[16px]">
203
- <div class="flex gap-[8px] w-full">
204
- <Avatar :size="30" :src="currentUser.avatar" />
205
- <div class="flex-1">
206
- <div :ref="(el) => setReplyInputRef(el, reply.id)" contenteditable="true"
207
- class="w-full min-h-[36px] px-[12px] py-[5px] rounded-md border border-mercury font-body-large focus:outline-none focus:ring-2 focus:ring-primary break-words bg-white empty:before:content-[attr(data-placeholder)] empty:before:text-gray"
208
- data-placeholder="เขียนการตอบกลับ..." @input="onReplyInput($event, reply.id)"
209
- @keydown.enter.exact.prevent="onSendReplyComment(cmt)"
210
- @keydown.shift.enter.prevent="onInsertLineBreak" />
211
- </div>
212
- </div>
213
- <div class="flex justify-end gap-[8px]">
214
- <Button variant="outline" class="w-[56px]" @click="onCancelReplyComment">
215
- ยกเลิก
216
- </Button>
217
- <Button color="primary" class="w-[68px]" :disabled="!replyTextMap[reply.id]"
218
- @click="onSendReplyComment(cmt)">
219
- ตอบกลับ
220
- </Button>
221
- </div>
222
- </div>
149
+ <!-- ตอบกลับ reply submit ยังผูกกับ comment แม่ (cmt) เหมือนเดิม -->
150
+ <InputComment v-if="replyingToId === reply.id" v-model="replyText" v-model:reply-to="replyToUser"
151
+ :avatar="currentUser.avatar" placeholder="เขียนการตอบกลับ..." submit-text="ตอบกลับ" autofocus
152
+ @submit="onSendReplyComment(cmt)" @cancel="onCancelReplyComment" />
223
153
  </div>
224
154
  </div>
225
155
  </div>
@@ -270,11 +200,9 @@ const expandedContentIds = ref([]);
270
200
  const truncatedTexts = ref({});
271
201
  const mainInputRef = ref(null);
272
202
  const mainCommentInput = ref("");
273
- const isShowMainActions = ref(false);
274
203
  const replyingToId = ref(null);
275
- const replyToUser = ref(null);
276
- const replyInputRefs = ref({});
277
- const replyTextMap = ref({});
204
+ const replyToUser = ref(void 0);
205
+ const replyText = ref("");
278
206
  const pinnedReplyIds = ref(/* @__PURE__ */ new Set());
279
207
  const knownRepliesPerComment = /* @__PURE__ */ new WeakMap();
280
208
  watch(
@@ -309,17 +237,9 @@ const hoveredId = ref(null);
309
237
  const openMenuId = ref(null);
310
238
  const isMenuVisible = (id) => hoveredId.value === id || openMenuId.value === id;
311
239
  const editingId = ref(null);
312
- const editInputRefs = ref({});
313
- const editTextMap = ref({});
240
+ const editText = ref("");
241
+ const editReplyTo = ref(void 0);
314
242
  const isSelfUser = (user) => !!props.currentUser.id && user.id === props.currentUser.id;
315
- const buildMentionChip = (name) => {
316
- const chip = document.createElement("span");
317
- chip.dataset.type = "mention";
318
- chip.contentEditable = "false";
319
- chip.className = "inline-flex items-center text-primary font-body-large-prominent select-none mr-1";
320
- chip.textContent = name;
321
- return chip;
322
- };
323
243
  const onToggleReplyComment = (id) => {
324
244
  if (openedComments.value.includes(id)) {
325
245
  openedComments.value = openedComments.value.filter((i) => i !== id);
@@ -395,113 +315,26 @@ const toggleContentExpand = (id) => {
395
315
  expandedContentIds.value.push(id);
396
316
  }
397
317
  };
398
- const clearMainInput = () => {
399
- mainCommentInput.value = "";
400
- if (mainInputRef.value) mainInputRef.value.innerText = "";
401
- };
402
- const onCancelComment = () => {
403
- clearMainInput();
404
- isShowMainActions.value = false;
405
- };
406
318
  const onSendComment = () => {
407
319
  if (!mainCommentInput.value.trim()) return;
408
320
  emit("submit", mainCommentInput.value.trim());
409
- clearMainInput();
410
- isShowMainActions.value = false;
411
- };
412
- const setReplyInputRef = (el, id) => {
413
- if (el) replyInputRefs.value[id] = el;
414
- else delete replyInputRefs.value[id];
321
+ mainInputRef.value?.reset();
415
322
  };
416
323
  const onReplyComment = (id, user) => {
417
- const isSelf = isSelfUser(user);
418
324
  replyingToId.value = id;
419
- replyToUser.value = isSelf ? null : user;
420
- replyTextMap.value[id] = "";
421
- nextTick(() => {
422
- const el = replyInputRefs.value[id];
423
- if (!el) return;
424
- el.innerHTML = "";
425
- if (!isSelf) {
426
- const space = document.createTextNode("\xA0");
427
- el.appendChild(buildMentionChip(user.name));
428
- el.appendChild(space);
429
- const range = document.createRange();
430
- const sel = window.getSelection();
431
- range.setStartAfter(space);
432
- range.collapse(true);
433
- sel?.removeAllRanges();
434
- sel?.addRange(range);
435
- }
436
- el.focus();
437
- });
438
- };
439
- const readTextWithBreaks = (el, skipMention = false) => {
440
- const nodes = Array.from(el.childNodes);
441
- while (nodes.length && nodes.at(-1)?.nodeName === "BR") nodes.pop();
442
- let text = "";
443
- nodes.forEach((node) => {
444
- if (skipMention && node.dataset?.type === "mention")
445
- return;
446
- if (node.nodeName === "BR") {
447
- text += "\n";
448
- return;
449
- }
450
- if (node.nodeName === "DIV") {
451
- text += "\n" + node.innerText;
452
- return;
453
- }
454
- text += node.textContent ?? "";
455
- });
456
- return text.replace(/\n{3,}/g, "\n\n").replace(/^\u00A0/, "").trim();
457
- };
458
- const onInsertLineBreak = (event) => {
459
- const el = event.target;
460
- const sel = window.getSelection();
461
- if (!sel || sel.rangeCount === 0) return;
462
- const range = sel.getRangeAt(0);
463
- range.deleteContents();
464
- const br = document.createElement("br");
465
- range.insertNode(br);
466
- if (!br.nextSibling || br.nextSibling.nodeName !== "BR" && br.nextSibling.textContent === "") {
467
- const phantom = document.createElement("br");
468
- br.after(phantom);
469
- }
470
- range.setStartAfter(br);
471
- range.collapse(true);
472
- sel.removeAllRanges();
473
- sel.addRange(range);
474
- el.dispatchEvent(new Event("input", { bubbles: true }));
475
- };
476
- const onMainCommentInput = (event) => {
477
- const el = event.target;
478
- mainCommentInput.value = readTextWithBreaks(el);
479
- };
480
- const onReplyInput = (event, id) => {
481
- const el = event.target;
482
- const chipExists = !!el.querySelector('[data-type="mention"]');
483
- if (!chipExists) replyToUser.value = null;
484
- replyTextMap.value[id] = readTextWithBreaks(el, true);
325
+ replyToUser.value = isSelfUser(user) ? void 0 : user;
326
+ replyText.value = "";
485
327
  };
486
328
  const onCancelReplyComment = () => {
487
- const id = replyingToId.value;
488
- if (id && replyInputRefs.value[id]) {
489
- replyInputRefs.value[id].innerHTML = "";
490
- delete replyTextMap.value[id];
491
- }
492
329
  replyingToId.value = null;
493
- replyToUser.value = null;
330
+ replyToUser.value = void 0;
331
+ replyText.value = "";
494
332
  };
495
333
  const onSendReplyComment = (comment) => {
496
- const id = replyingToId.value;
497
- if (!id) return;
498
- const content = replyTextMap.value[id]?.trim() ?? "";
334
+ const content = replyText.value.trim();
499
335
  if (!content) return;
500
336
  emit("reply", comment.id, replyToUser.value?.id, content);
501
- if (replyInputRefs.value[id]) replyInputRefs.value[id].innerHTML = "";
502
- delete replyTextMap.value[id];
503
- replyingToId.value = null;
504
- replyToUser.value = null;
337
+ onCancelReplyComment();
505
338
  };
506
339
  const onToggleLikeComment = (comment) => {
507
340
  emit("like-comment", comment);
@@ -512,47 +345,22 @@ const onToggleLikeReplyComment = (reply) => {
512
345
  const onViewProfileComment = (id) => {
513
346
  if (id) router.push(`/@${id}`);
514
347
  };
515
- const setEditInputRef = (el, id) => {
516
- if (el) editInputRefs.value[id] = el;
517
- else delete editInputRefs.value[id];
518
- };
519
348
  const onEditClick = (id, content, replyTo) => {
520
349
  editingId.value = id;
521
- editTextMap.value[id] = content;
522
- nextTick(() => {
523
- const el = editInputRefs.value[id];
524
- if (!el) return;
525
- el.innerHTML = "";
526
- if (replyTo) {
527
- el.appendChild(buildMentionChip(replyTo.name));
528
- el.appendChild(document.createTextNode("\xA0"));
529
- }
530
- el.appendChild(document.createTextNode(content));
531
- const range = document.createRange();
532
- range.selectNodeContents(el);
533
- range.collapse(false);
534
- const sel = window.getSelection();
535
- sel?.removeAllRanges();
536
- sel?.addRange(range);
537
- el.focus();
538
- });
539
- };
540
- const onEditInput = (event, id) => {
541
- const el = event.target;
542
- editTextMap.value[id] = readTextWithBreaks(el, true);
350
+ editText.value = content;
351
+ editReplyTo.value = replyTo;
543
352
  };
544
353
  const onCancelEdit = () => {
545
- const id = editingId.value;
546
- if (id) delete editTextMap.value[id];
547
354
  editingId.value = null;
355
+ editText.value = "";
356
+ editReplyTo.value = void 0;
548
357
  };
549
358
  const onSendEdit = (item, type) => {
550
- const content = editTextMap.value[item.id]?.trim() ?? "";
359
+ const content = editText.value.trim();
551
360
  if (!content) return;
552
361
  if (type === "comment") emit("edit-comment", item, content);
553
362
  else emit("edit-reply", item, content);
554
- delete editTextMap.value[item.id];
555
- editingId.value = null;
363
+ onCancelEdit();
556
364
  };
557
365
  const getCommentMenuItems = (comment) => isSelfUser(comment.user) ? [
558
366
  {
@@ -1,13 +1,58 @@
1
- import type { InputCommentProps } from "#pukaad-ui/types/components/input/input-comment";
1
+ import type { HTMLAttributes } from "vue";
2
+ import type { User } from "#pukaad-ui/types/components/comment.d.ts";
3
+ export interface InputCommentProps {
4
+ /** อวาตาร์ของคนที่กำลังพิมพ์ — ส่ง undefined ได้ Avatar จะขึ้นตัวแทน */
5
+ avatar?: string;
6
+ /** ไม่ต้องวาดอวาตาร์ — ใช้ตอนที่แถวข้างนอกมีอวาตาร์อยู่แล้ว (กล่องแก้ไขของ Comment) */
7
+ disabledAvatar?: boolean;
8
+ placeholder?: string;
9
+ disabled?: boolean;
10
+ /** กำลังส่ง — ล็อกช่องพิมพ์ + spinner ที่ปุ่มส่ง */
11
+ loading?: boolean;
12
+ submitText?: string;
13
+ cancelText?: string;
14
+ /** ซ่อนปุ่มไว้ก่อน โผล่ตอนคลิกเข้าช่องพิมพ์ (กล่องคอมเมนต์หลักของ Comment) */
15
+ actionsOnFocus?: boolean;
16
+ /** โฟกัส + วาง caret ท้ายข้อความทันทีที่ mount — สำหรับกล่องที่โผล่จากการกดปุ่ม (ตอบกลับ/แก้ไข) */
17
+ autofocus?: boolean;
18
+ /** ไม่ต้องมีปุ่ม — ให้ parent คุมเอง */
19
+ disabledActions?: boolean;
20
+ class?: HTMLAttributes["class"];
21
+ }
2
22
  type __VLS_Props = InputCommentProps;
3
23
  type __VLS_ModelProps = {
4
- modelValue?: string | undefined;
24
+ /** ข้อความล้วน ไม่รวมชื่อคนที่ตอบกลับ (ชิป mention ถูกข้ามตอนอ่าน) */
25
+ modelValue?: string;
26
+ /**
27
+ * คนที่ถูกตอบกลับ → แสดงเป็นชิปสี primary หน้าข้อความ
28
+ * เป็น v-model เพราะผู้ใช้ลบชิปทิ้งได้ แล้วคอมโพเนนต์ต้องบอก parent กลับ
29
+ */
30
+ "replyTo"?: User | undefined;
5
31
  };
6
32
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
7
- declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
- "update:modelValue": (value: string | undefined) => any;
33
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {
34
+ focus: () => void;
35
+ reset: () => void;
36
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
37
+ submit: (value: string) => any;
38
+ cancel: () => any;
39
+ "update:modelValue": (value: string) => any;
40
+ "update:replyTo": (value: User | undefined) => any;
9
41
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
10
- "onUpdate:modelValue"?: ((value: string | undefined) => any) | undefined;
11
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
42
+ onSubmit?: ((value: string) => any) | undefined;
43
+ onCancel?: (() => any) | undefined;
44
+ "onUpdate:modelValue"?: ((value: string) => any) | undefined;
45
+ "onUpdate:replyTo"?: ((value: User | undefined) => any) | undefined;
46
+ }>, {
47
+ loading: boolean;
48
+ disabled: boolean;
49
+ placeholder: string;
50
+ disabledAvatar: boolean;
51
+ submitText: string;
52
+ cancelText: string;
53
+ actionsOnFocus: boolean;
54
+ autofocus: boolean;
55
+ disabledActions: boolean;
56
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
57
  declare const _default: typeof __VLS_export;
13
58
  export default _default;
@@ -1,43 +1,197 @@
1
1
  <template>
2
- <div
3
- contenteditable="true"
4
- ref="editorRef"
5
- @input="onInput"
6
- class="w-full min-h-[40px] px-[12px] py-[8px] font-body-large bg-white ring-[1px] ring-cloud rounded-[8px] outline-none focus:outline-none focus:ring-[2px] focus:ring-primary whitespace-pre-wrap break-words text-black"
7
- />
2
+ <!-- Figma "Input - Comments / Reply" (2456:53525 → 1981:2017): avatar + ช่องพิมพ์ / ปุ่มชิดขวา -->
3
+ <div :class="['flex flex-col gap-[16px] items-end w-full', props.class]">
4
+ <!-- focusin (ไม่ใช่ focus) เพราะต้องการให้ bubble ขึ้นมาจากช่องพิมพ์ข้างใน -->
5
+ <div class="flex gap-[8px] items-start w-full" @focusin="isFocused = true">
6
+ <!-- pt-[5px] ดันอวาตาร์ลงให้ตรงบรรทัดแรกของข้อความ (Figma 2477:33564) -->
7
+ <div
8
+ v-if="!props.disabledAvatar"
9
+ class="flex items-center pt-[5px] shrink-0"
10
+ >
11
+ <Avatar :src="props.avatar" :size="30" />
12
+ </div>
13
+ <div
14
+ ref="editorRef"
15
+ :contenteditable="isLocked ? 'false' : 'true'"
16
+ :data-placeholder="props.placeholder"
17
+ :class="[
18
+ 'relative flex-1 min-w-0 min-h-[36px] px-[12px] py-[5px] rounded-md border border-mercury bg-white shadow-xs font-body-large text-black',
19
+ 'whitespace-pre-wrap break-words outline-none focus:outline-none focus:ring-2 focus:ring-primary',
20
+ isEmpty && 'before:content-[attr(data-placeholder)] before:text-gray before:absolute before:pointer-events-none',
21
+ isLocked && 'opacity-50 cursor-not-allowed'
22
+ ]"
23
+ @input="onInput"
24
+ @keydown.enter.exact.prevent="onEnter"
25
+ @keydown.shift.enter.prevent="onInsertLineBreak"
26
+ />
27
+ </div>
28
+
29
+ <div
30
+ v-if="isActionsVisible"
31
+ class="flex gap-[8px] items-center justify-end w-full"
32
+ >
33
+ <Button
34
+ variant="outline"
35
+ size="sm"
36
+ :disabled="props.loading"
37
+ @click="onCancel"
38
+ >
39
+ {{ props.cancelText }}
40
+ </Button>
41
+ <Button
42
+ color="primary"
43
+ size="sm"
44
+ :disabled="!modelValue.trim() || props.loading"
45
+ :loading="props.loading"
46
+ @click="onEnter"
47
+ >
48
+ {{ props.submitText }}
49
+ </Button>
50
+ </div>
51
+ </div>
8
52
  </template>
9
53
 
10
54
  <script setup>
11
- import { ref, watch, onMounted, nextTick } from "vue";
55
+ import { ref, computed, watch, onMounted, nextTick } from "vue";
12
56
  const props = defineProps({
13
- userReply: { type: Object, required: false }
57
+ avatar: { type: String, required: false },
58
+ disabledAvatar: { type: Boolean, required: false, default: false },
59
+ placeholder: { type: String, required: false, default: "" },
60
+ disabled: { type: Boolean, required: false, default: false },
61
+ loading: { type: Boolean, required: false, default: false },
62
+ submitText: { type: String, required: false, default: "\u0E2A\u0E48\u0E07" },
63
+ cancelText: { type: String, required: false, default: "\u0E22\u0E01\u0E40\u0E25\u0E34\u0E01" },
64
+ actionsOnFocus: { type: Boolean, required: false, default: false },
65
+ autofocus: { type: Boolean, required: false, default: false },
66
+ disabledActions: { type: Boolean, required: false, default: false },
67
+ class: { type: null, required: false }
14
68
  });
15
- const modelValue = defineModel({ type: null });
69
+ const emit = defineEmits(["submit", "cancel"]);
70
+ const modelValue = defineModel({ type: String, ...{ default: "" } });
71
+ const replyTo = defineModel("replyTo", { type: null });
16
72
  const editorRef = ref(null);
73
+ const isFocused = ref(false);
74
+ const isLocked = computed(() => props.disabled || props.loading);
75
+ const isEmpty = computed(() => !modelValue.value.trim() && !replyTo.value);
76
+ const isActionsVisible = computed(() => {
77
+ if (props.disabledActions) return false;
78
+ return !props.actionsOnFocus || isFocused.value;
79
+ });
80
+ const buildMentionChip = (name) => {
81
+ const chip = document.createElement("span");
82
+ chip.dataset.type = "mention";
83
+ chip.contentEditable = "false";
84
+ chip.className = "inline-flex items-center text-primary font-body-large-prominent select-none mr-1";
85
+ chip.textContent = name;
86
+ return chip;
87
+ };
88
+ const readText = (el) => {
89
+ const nodes = Array.from(el.childNodes);
90
+ while (nodes.length && nodes.at(-1)?.nodeName === "BR") nodes.pop();
91
+ let text = "";
92
+ nodes.forEach((node) => {
93
+ if (node.dataset?.type === "mention") return;
94
+ if (node.nodeName === "BR") {
95
+ text += "\n";
96
+ return;
97
+ }
98
+ if (node.nodeName === "DIV") {
99
+ text += "\n" + node.innerText;
100
+ return;
101
+ }
102
+ text += node.textContent ?? "";
103
+ });
104
+ return text.replace(/\n{3,}/g, "\n\n").replace(/^\u00A0/, "").trim();
105
+ };
106
+ const render = () => {
107
+ const el = editorRef.value;
108
+ if (!el) return;
109
+ el.innerHTML = "";
110
+ if (replyTo.value) {
111
+ el.appendChild(buildMentionChip(replyTo.value.name));
112
+ el.appendChild(document.createTextNode("\xA0"));
113
+ }
114
+ modelValue.value.split("\n").forEach((line, i) => {
115
+ if (i) el.appendChild(document.createElement("br"));
116
+ if (line) el.appendChild(document.createTextNode(line));
117
+ });
118
+ };
119
+ const focusEnd = () => {
120
+ const el = editorRef.value;
121
+ if (!el) return;
122
+ const range = document.createRange();
123
+ range.selectNodeContents(el);
124
+ range.collapse(false);
125
+ const sel = window.getSelection();
126
+ sel?.removeAllRanges();
127
+ sel?.addRange(range);
128
+ };
129
+ const chipName = (el) => el.querySelector('[data-type="mention"]')?.textContent ?? void 0;
130
+ const onInput = () => {
131
+ const el = editorRef.value;
132
+ if (!el) return;
133
+ isFocused.value = true;
134
+ if (replyTo.value && !chipName(el)) replyTo.value = void 0;
135
+ modelValue.value = readText(el);
136
+ };
17
137
  onMounted(() => {
18
- if (editorRef.value) {
19
- editorRef.value.innerText = modelValue.value ?? "";
138
+ render();
139
+ if (props.autofocus) nextTick(() => focus());
140
+ });
141
+ watch([modelValue, replyTo], ([, user], [, prevUser]) => {
142
+ const el = editorRef.value;
143
+ if (!el) return;
144
+ if (readText(el) === modelValue.value && chipName(el) === user?.name) return;
145
+ render();
146
+ const justMentioned = !!user && user !== prevUser;
147
+ if (justMentioned || document.activeElement === el) {
148
+ nextTick(() => {
149
+ el.focus();
150
+ focusEnd();
151
+ });
20
152
  }
21
153
  });
22
- const onInput = (e) => {
23
- const target = e.target;
24
- modelValue.value = target.innerText;
25
- };
26
- watch(
27
- () => modelValue.value,
28
- (val) => {
29
- if (editorRef.value) {
30
- const next = val ?? "";
31
- if (editorRef.value.innerText !== next) editorRef.value.innerText = next;
32
- }
154
+ const onEnter = () => {
155
+ if (isLocked.value || !modelValue.value.trim()) return;
156
+ emit("submit", modelValue.value);
157
+ };
158
+ const onCancel = () => {
159
+ modelValue.value = "";
160
+ replyTo.value = void 0;
161
+ isFocused.value = false;
162
+ emit("cancel");
163
+ };
164
+ const onInsertLineBreak = (event) => {
165
+ if (isLocked.value) return;
166
+ const el = event.target;
167
+ const sel = window.getSelection();
168
+ if (!sel || sel.rangeCount === 0) return;
169
+ const range = sel.getRangeAt(0);
170
+ range.deleteContents();
171
+ const br = document.createElement("br");
172
+ range.insertNode(br);
173
+ if (!br.nextSibling || br.nextSibling.nodeName !== "BR" && br.nextSibling.textContent === "") {
174
+ const phantom = document.createElement("br");
175
+ br.after(phantom);
33
176
  }
34
- );
35
- watch(() => props.userReply, (user) => {
36
- if (!user) return;
37
- const mention = `${user.name} `;
38
- const current = modelValue.value ?? "";
39
- const withoutPrev = current.replace(/^\S+\s+/, "");
40
- const next = mention + withoutPrev;
41
- modelValue.value = next;
42
- }, { immediate: true });
177
+ range.setStartAfter(br);
178
+ range.collapse(true);
179
+ sel.removeAllRanges();
180
+ sel.addRange(range);
181
+ el.dispatchEvent(new Event("input", { bubbles: true }));
182
+ };
183
+ const focus = () => {
184
+ isFocused.value = true;
185
+ editorRef.value?.focus();
186
+ focusEnd();
187
+ };
188
+ const reset = () => {
189
+ modelValue.value = "";
190
+ replyTo.value = void 0;
191
+ isFocused.value = false;
192
+ };
193
+ defineExpose({
194
+ focus,
195
+ reset
196
+ });
43
197
  </script>
@@ -1,13 +1,58 @@
1
- import type { InputCommentProps } from "#pukaad-ui/types/components/input/input-comment";
1
+ import type { HTMLAttributes } from "vue";
2
+ import type { User } from "#pukaad-ui/types/components/comment.d.ts";
3
+ export interface InputCommentProps {
4
+ /** อวาตาร์ของคนที่กำลังพิมพ์ — ส่ง undefined ได้ Avatar จะขึ้นตัวแทน */
5
+ avatar?: string;
6
+ /** ไม่ต้องวาดอวาตาร์ — ใช้ตอนที่แถวข้างนอกมีอวาตาร์อยู่แล้ว (กล่องแก้ไขของ Comment) */
7
+ disabledAvatar?: boolean;
8
+ placeholder?: string;
9
+ disabled?: boolean;
10
+ /** กำลังส่ง — ล็อกช่องพิมพ์ + spinner ที่ปุ่มส่ง */
11
+ loading?: boolean;
12
+ submitText?: string;
13
+ cancelText?: string;
14
+ /** ซ่อนปุ่มไว้ก่อน โผล่ตอนคลิกเข้าช่องพิมพ์ (กล่องคอมเมนต์หลักของ Comment) */
15
+ actionsOnFocus?: boolean;
16
+ /** โฟกัส + วาง caret ท้ายข้อความทันทีที่ mount — สำหรับกล่องที่โผล่จากการกดปุ่ม (ตอบกลับ/แก้ไข) */
17
+ autofocus?: boolean;
18
+ /** ไม่ต้องมีปุ่ม — ให้ parent คุมเอง */
19
+ disabledActions?: boolean;
20
+ class?: HTMLAttributes["class"];
21
+ }
2
22
  type __VLS_Props = InputCommentProps;
3
23
  type __VLS_ModelProps = {
4
- modelValue?: string | undefined;
24
+ /** ข้อความล้วน ไม่รวมชื่อคนที่ตอบกลับ (ชิป mention ถูกข้ามตอนอ่าน) */
25
+ modelValue?: string;
26
+ /**
27
+ * คนที่ถูกตอบกลับ → แสดงเป็นชิปสี primary หน้าข้อความ
28
+ * เป็น v-model เพราะผู้ใช้ลบชิปทิ้งได้ แล้วคอมโพเนนต์ต้องบอก parent กลับ
29
+ */
30
+ "replyTo"?: User | undefined;
5
31
  };
6
32
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
7
- declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
- "update:modelValue": (value: string | undefined) => any;
33
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {
34
+ focus: () => void;
35
+ reset: () => void;
36
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
37
+ submit: (value: string) => any;
38
+ cancel: () => any;
39
+ "update:modelValue": (value: string) => any;
40
+ "update:replyTo": (value: User | undefined) => any;
9
41
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
10
- "onUpdate:modelValue"?: ((value: string | undefined) => any) | undefined;
11
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
42
+ onSubmit?: ((value: string) => any) | undefined;
43
+ onCancel?: (() => any) | undefined;
44
+ "onUpdate:modelValue"?: ((value: string) => any) | undefined;
45
+ "onUpdate:replyTo"?: ((value: User | undefined) => any) | undefined;
46
+ }>, {
47
+ loading: boolean;
48
+ disabled: boolean;
49
+ placeholder: string;
50
+ disabledAvatar: boolean;
51
+ submitText: string;
52
+ cancelText: string;
53
+ actionsOnFocus: boolean;
54
+ autofocus: boolean;
55
+ disabledActions: boolean;
56
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
57
  declare const _default: typeof __VLS_export;
13
58
  export default _default;
@@ -0,0 +1,24 @@
1
+ import type { HTMLAttributes } from "vue";
2
+ export type LabelCardContentOverflow = "slide" | "ellipsis";
3
+ export type LabelCardContentItem = {
4
+ text: string;
5
+ color: string;
6
+ isSpecial?: boolean;
7
+ };
8
+ export interface LabelCardContentProps {
9
+ class?: HTMLAttributes["class"];
10
+ items?: LabelCardContentItem | LabelCardContentItem[];
11
+ overflow?: LabelCardContentOverflow;
12
+ clickable?: boolean;
13
+ }
14
+ declare const __VLS_export: import("vue").DefineComponent<LabelCardContentProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
+ click: (item: LabelCardContentItem) => any;
16
+ }, string, import("vue").PublicProps, Readonly<LabelCardContentProps> & Readonly<{
17
+ onClick?: ((item: LabelCardContentItem) => any) | undefined;
18
+ }>, {
19
+ items: LabelCardContentItem | LabelCardContentItem[];
20
+ overflow: LabelCardContentOverflow;
21
+ clickable: boolean;
22
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
23
+ declare const _default: typeof __VLS_export;
24
+ export default _default;
@@ -0,0 +1,105 @@
1
+ <template>
2
+ <div :class="['relative', props.class]">
3
+ <div
4
+ ref="scroller"
5
+ class="flex items-center gap-[8px] scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
6
+ :class="isEllipsis ? 'overflow-hidden' : 'overflow-x-auto'"
7
+ @scroll="syncArrows"
8
+ >
9
+ <div
10
+ v-for="(item, index) in labels"
11
+ :key="`${item.text}-${index}`"
12
+ :class="[
13
+ 'inline-flex items-center rounded-[2px] border-2 border-solid px-[6px] py-[2px] font-title-small-prominent',
14
+ item.isSpecial === false ? 'bg-white' : 'text-white',
15
+ isEllipsis ? 'min-w-0' : 'shrink-0',
16
+ props.clickable && 'cursor-pointer'
17
+ ]"
18
+ :style="itemStyles(item)"
19
+ @click="props.clickable && emit('click', item)"
20
+ >
21
+ <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{{
22
+ item.text
23
+ }}</span>
24
+ </div>
25
+ </div>
26
+
27
+ <template v-if="canSlide">
28
+ <div
29
+ v-if="canScrollLeft"
30
+ class="absolute top-0 left-0 flex h-[32px] w-[50px] items-center bg-gradient-to-r from-bright from-50% to-transparent pl-[4px]"
31
+ >
32
+ <Button
33
+ variant="icon"
34
+ circle
35
+ class="size-[24px] hover:bg-mercury hover:drop-shadow"
36
+ @click="slide(-1)"
37
+ >
38
+ <Icon name="lucide:chevron-left" size="16" />
39
+ </Button>
40
+ </div>
41
+
42
+ <div
43
+ v-if="canScrollRight"
44
+ class="absolute top-0 right-0 flex h-[32px] w-[50px] items-center justify-end bg-gradient-to-l from-bright from-50% to-transparent pr-[4px]"
45
+ >
46
+ <Button
47
+ variant="icon"
48
+ circle
49
+ class="size-[24px] hover:bg-mercury hover:drop-shadow"
50
+ @click="slide(1)"
51
+ >
52
+ <Icon name="lucide:chevron-right" size="16" />
53
+ </Button>
54
+ </div>
55
+ </template>
56
+ </div>
57
+ </template>
58
+
59
+ <script setup>
60
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
61
+ const props = defineProps({
62
+ class: { type: null, required: false },
63
+ items: { type: [Object, Array], required: false, default: () => [] },
64
+ overflow: { type: String, required: false, default: "slide" },
65
+ clickable: { type: Boolean, required: false, default: false }
66
+ });
67
+ const emit = defineEmits(["click"]);
68
+ const labels = computed(
69
+ () => Array.isArray(props.items) ? props.items : [props.items]
70
+ );
71
+ const isEllipsis = computed(() => props.overflow === "ellipsis");
72
+ const canSlide = computed(() => !isEllipsis.value);
73
+ const itemStyles = (item) => {
74
+ const color = item.color?.startsWith("#") ? item.color : `#${item.color}`;
75
+ return item.isSpecial === false ? { color, borderColor: color } : { backgroundColor: color, borderColor: color };
76
+ };
77
+ const scroller = ref(null);
78
+ const canScrollLeft = ref(false);
79
+ const canScrollRight = ref(false);
80
+ const SCROLL_EPSILON = 1;
81
+ const syncArrows = () => {
82
+ const element = scroller.value;
83
+ if (!element) return;
84
+ canScrollLeft.value = element.scrollLeft > SCROLL_EPSILON;
85
+ canScrollRight.value = element.scrollLeft + element.clientWidth < element.scrollWidth - SCROLL_EPSILON;
86
+ };
87
+ const slide = (direction) => {
88
+ const element = scroller.value;
89
+ if (!element) return;
90
+ element.scrollBy({
91
+ left: direction * element.clientWidth * 0.8,
92
+ behavior: "smooth"
93
+ });
94
+ };
95
+ let observer;
96
+ onMounted(() => {
97
+ syncArrows();
98
+ if (scroller.value && typeof ResizeObserver !== "undefined") {
99
+ observer = new ResizeObserver(syncArrows);
100
+ observer.observe(scroller.value);
101
+ }
102
+ });
103
+ onBeforeUnmount(() => observer?.disconnect());
104
+ watch(labels, syncArrows, { flush: "post" });
105
+ </script>
@@ -0,0 +1,24 @@
1
+ import type { HTMLAttributes } from "vue";
2
+ export type LabelCardContentOverflow = "slide" | "ellipsis";
3
+ export type LabelCardContentItem = {
4
+ text: string;
5
+ color: string;
6
+ isSpecial?: boolean;
7
+ };
8
+ export interface LabelCardContentProps {
9
+ class?: HTMLAttributes["class"];
10
+ items?: LabelCardContentItem | LabelCardContentItem[];
11
+ overflow?: LabelCardContentOverflow;
12
+ clickable?: boolean;
13
+ }
14
+ declare const __VLS_export: import("vue").DefineComponent<LabelCardContentProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
+ click: (item: LabelCardContentItem) => any;
16
+ }, string, import("vue").PublicProps, Readonly<LabelCardContentProps> & Readonly<{
17
+ onClick?: ((item: LabelCardContentItem) => any) | undefined;
18
+ }>, {
19
+ items: LabelCardContentItem | LabelCardContentItem[];
20
+ overflow: LabelCardContentOverflow;
21
+ clickable: boolean;
22
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
23
+ declare const _default: typeof __VLS_export;
24
+ export default _default;
@@ -5,9 +5,12 @@ declare const __VLS_export: import("vue").DefineComponent<PickerOptionMenuUserPr
5
5
  "blog-unpin": () => any;
6
6
  "blog-pin": () => any;
7
7
  "blog-delete": () => any;
8
+ "reply-delete": () => any;
8
9
  "report-review": () => any;
9
10
  "review-edit": () => any;
10
11
  "review-delete": () => any;
12
+ "review-reply": () => any;
13
+ "reply-edit": () => any;
11
14
  "profile-name-updated": (name: string) => any;
12
15
  "profile-edit": () => any;
13
16
  "profile-share": () => any;
@@ -24,9 +27,12 @@ declare const __VLS_export: import("vue").DefineComponent<PickerOptionMenuUserPr
24
27
  "onBlog-unpin"?: (() => any) | undefined;
25
28
  "onBlog-pin"?: (() => any) | undefined;
26
29
  "onBlog-delete"?: (() => any) | undefined;
30
+ "onReply-delete"?: (() => any) | undefined;
27
31
  "onReport-review"?: (() => any) | undefined;
28
32
  "onReview-edit"?: (() => any) | undefined;
29
33
  "onReview-delete"?: (() => any) | undefined;
34
+ "onReview-reply"?: (() => any) | undefined;
35
+ "onReply-edit"?: (() => any) | undefined;
30
36
  "onProfile-name-updated"?: ((name: string) => any) | undefined;
31
37
  "onProfile-edit"?: (() => any) | undefined;
32
38
  "onProfile-share"?: (() => any) | undefined;
@@ -21,7 +21,7 @@
21
21
 
22
22
  <script setup>
23
23
  import { computed, ref } from "vue";
24
- const emit = defineEmits(["profile-edit", "profile-share", "profile-follower-delete", "profile-block", "blog-pin", "blog-unpin", "blog-edit", "blog-delete", "report-blog", "report-place", "report-profile", "report-announce", "report-review", "place-close-duplicate", "review-edit", "review-delete", "archive", "profile-name-updated"]);
24
+ const emit = defineEmits(["profile-edit", "profile-share", "profile-follower-delete", "profile-block", "blog-pin", "blog-unpin", "blog-edit", "blog-delete", "report-blog", "report-place", "report-profile", "report-announce", "report-review", "place-close-duplicate", "review-edit", "review-delete", "review-reply", "reply-edit", "reply-delete", "archive", "profile-name-updated"]);
25
25
  const props = defineProps({
26
26
  state: { type: String, required: false },
27
27
  profileDetail: { type: Object, required: false },
@@ -162,6 +162,16 @@ const menu = [
162
162
  emit("report-announce");
163
163
  }
164
164
  },
165
+ // ลำดับใน array นี้ = ลำดับที่แสดงในเมนู (items ใช้ menu.filter ซึ่งคงลำดับเดิมไว้)
166
+ // "ตอบกลับ" ต้องอยู่เหนือ "รายงานรีวิวนี้" ตาม Figma 2456:51570
167
+ {
168
+ label: "\u0E15\u0E2D\u0E1A\u0E01\u0E25\u0E31\u0E1A",
169
+ name: "review-reply",
170
+ icon: "lucide:reply",
171
+ action: () => {
172
+ emit("review-reply");
173
+ }
174
+ },
165
175
  {
166
176
  label: "\u0E23\u0E32\u0E22\u0E07\u0E32\u0E19\u0E23\u0E35\u0E27\u0E34\u0E27\u0E19\u0E35\u0E49",
167
177
  name: "report-review",
@@ -187,6 +197,23 @@ const menu = [
187
197
  action: () => {
188
198
  emit("review-delete");
189
199
  }
200
+ },
201
+ // เมนูของกล่อง "การตอบกลับจาก ..." ที่ธุรกิจตอบไว้ (Figma 2456:88751)
202
+ {
203
+ label: "\u0E41\u0E01\u0E49\u0E44\u0E02",
204
+ name: "reply-edit",
205
+ icon: "lucide:square-pen",
206
+ action: () => {
207
+ emit("reply-edit");
208
+ }
209
+ },
210
+ {
211
+ label: "\u0E25\u0E1A",
212
+ name: "reply-delete",
213
+ icon: "lucide:trash-2",
214
+ action: () => {
215
+ emit("reply-delete");
216
+ }
190
217
  }
191
218
  ];
192
219
  const items = computed(() => {
@@ -242,6 +269,16 @@ const items = computed(() => {
242
269
  (item) => ["report-review"].includes(item.name)
243
270
  );
244
271
  }
272
+ if (props.state === "business-review") {
273
+ return menu.filter(
274
+ (item) => ["review-reply", "report-review"].includes(item.name)
275
+ );
276
+ }
277
+ if (props.state === "my-reply") {
278
+ return menu.filter(
279
+ (item) => ["reply-edit", "reply-delete"].includes(item.name)
280
+ );
281
+ }
245
282
  return menu;
246
283
  });
247
284
  </script>
@@ -5,9 +5,12 @@ declare const __VLS_export: import("vue").DefineComponent<PickerOptionMenuUserPr
5
5
  "blog-unpin": () => any;
6
6
  "blog-pin": () => any;
7
7
  "blog-delete": () => any;
8
+ "reply-delete": () => any;
8
9
  "report-review": () => any;
9
10
  "review-edit": () => any;
10
11
  "review-delete": () => any;
12
+ "review-reply": () => any;
13
+ "reply-edit": () => any;
11
14
  "profile-name-updated": (name: string) => any;
12
15
  "profile-edit": () => any;
13
16
  "profile-share": () => any;
@@ -24,9 +27,12 @@ declare const __VLS_export: import("vue").DefineComponent<PickerOptionMenuUserPr
24
27
  "onBlog-unpin"?: (() => any) | undefined;
25
28
  "onBlog-pin"?: (() => any) | undefined;
26
29
  "onBlog-delete"?: (() => any) | undefined;
30
+ "onReply-delete"?: (() => any) | undefined;
27
31
  "onReport-review"?: (() => any) | undefined;
28
32
  "onReview-edit"?: (() => any) | undefined;
29
33
  "onReview-delete"?: (() => any) | undefined;
34
+ "onReview-reply"?: (() => any) | undefined;
35
+ "onReply-edit"?: (() => any) | undefined;
30
36
  "onProfile-name-updated"?: ((name: string) => any) | undefined;
31
37
  "onProfile-edit"?: (() => any) | undefined;
32
38
  "onProfile-share"?: (() => any) | undefined;
@@ -22,7 +22,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {},
22
22
  onOpenAutoFocus?: ((event: Event) => any) | undefined;
23
23
  onCloseAutoFocus?: ((event: Event) => any) | undefined;
24
24
  }>, {
25
- align: "center" | "start" | "end";
25
+ align: "start" | "center" | "end";
26
26
  sideOffset: number;
27
27
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
28
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
@@ -22,7 +22,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {},
22
22
  onOpenAutoFocus?: ((event: Event) => any) | undefined;
23
23
  onCloseAutoFocus?: ((event: Event) => any) | undefined;
24
24
  }>, {
25
- align: "center" | "start" | "end";
25
+ align: "start" | "center" | "end";
26
26
  sideOffset: number;
27
27
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
28
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pukaad-ui-lib",
3
- "version": "1.351.0",
3
+ "version": "1.352.0",
4
4
  "description": "pukaad-ui for MeMSG",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +0,0 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
- declare const _default: typeof __VLS_export;
3
- export default _default;
@@ -1,17 +0,0 @@
1
- <template>
2
- <!-- <div :class="['flex py-[2px]', state, color]">
3
- <div class="font-body-extra-small text-word-on-surface">
4
- {{ props.text }}
5
- </div>
6
- </div> -->
7
- </template>
8
-
9
- <script setup>
10
-
11
- </script>
12
-
13
- <style scoped>
14
- /* .state-last {
15
- // clip-path: "polygon(0 0, 100% 0, 90% 100%, 0 100%)";
16
- // } */
17
- </style>
@@ -1,3 +0,0 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
- declare const _default: typeof __VLS_export;
3
- export default _default;