1<template>
2 <div class="flex-1 min-w-0">
3 <!-- Header: title + sort tabs -->
4 <div class="flex items-center justify-between pb-4">
5 <h4 class="font-semibold text-ink-gray-8">
6 {{ __("User Reviews") }}
7 </h4>
8 <TabButtons
9 v-model="activeSort"
10 :options="sortOptions"
11 @update:modelValue="onSortChange"
12 />
13 </div>
14
15 <!-- Loading -->
16 <div v-if="feedbackListResource.loading" class="flex justify-center py-10">
17 <LoadingIndicator class="size-10" />
18 </div>
19
20 <!-- Empty state -->
21 <div
22 v-else-if="!feedbackListResource.data?.length"
23 class="flex flex-col items-center justify-center gap-3 py-16 text-center h-full flex-1"
24 >
25 <LucideMessageSquare class="h-10 w-10 text-ink-gray-4" />
26 <div>
27 <p class="text-md-medium text-ink-gray-7">
28 {{ __("No reviews found") }}
29 </p>
30 </div>
31 </div>
32
33 <!-- List -->
34 <div v-else class="flex flex-col gap-2 max-h-[65vh] overflow-y-auto">
35 <template v-for="ticket in feedbackListResource.data" :key="ticket.name">
36 <div
37 class="flex cursor-pointer flex-col gap-2 rounded-5 border border-outline-gray-1 bg-surface-base p-2.5 hover:bg-surface-gray-1"
38 @click="goToTicket(ticket.name)"
39 >
40 <!-- Rating badge + ticket name -->
41 <div class="flex items-center gap-2">
42 <span
43 class="inline-flex shrink-0 items-center gap-1 rounded-1 px-1.5 py-1 text-sm"
44 :class="ratingBadgeClass(ticket.feedback_rating)"
45 >
46 <LucideStar class="size-3 fill-current" />
47 {{ formatRating(ticket.feedback_rating) }}
48 </span>
49 <p class="min-w-0 flex-1 truncate text-sm">
50 <span class="text-ink-gray-5"># {{ ticket.name }}</span>
51 <span class="ml-1 font-medium text-ink-gray-7">{{
52 ticket.subject
53 }}</span>
54 </p>
55 <Tooltip :text="dayjs(ticket.modified).format('LLL')">
56 <span class="shrink-0 text-xs text-ink-gray-5">
57 {{ dayjs(ticket.modified).fromNow() }}
58 </span>
59 </Tooltip>
60 </div>
61
62 <!-- Feedback title + body -->
63 <div class="flex flex-col gap-1.5">
64 <p v-if="ticket.feedback" class="text-sm-medium text-ink-gray-7">
65 {{ ticket.feedback }}
66 </p>
67 <p v-if="ticket.feedback_extra" class="text-p-base text-ink-gray-6">
68 {{ ticket.feedback_extra }}
69 </p>
70 </div>
71 </div>
72 </template>
73
74 <!-- Load More -->
75 <div
76 class="flex justify-center py-6"
77 v-if="feedbackListResource.hasNextPage"
78 >
79 <Button
80 :loading="feedbackListResource.loading"
81 :label="__('Load More')"
82 icon-left="lucide-refresh-cw"
83 @click="feedbackListResource.next()"
84 />
85 </div>
86 </div>
87 </div>
88</template>
89
90<script setup lang="ts">
91import { useContactFeedback } from "@/composables/contact";
92import { __ } from "@/translation";
93import { dayjs, LoadingIndicator, TabButtons, Tooltip } from "frappe-ui";
94import { onMounted, ref } from "vue";
95import { useRouter } from "vue-router";
96import LucideMessageSquare from "~icons/lucide/message-square";
97import LucideStar from "~icons/lucide/star";
98
99const props = defineProps<{
100 name: string;
101}>();
102
103const { feedbackListResource, feedbackCount } = useContactFeedback(props.name);
104
105const router = useRouter();
106
107function goToTicket(name: string) {
108 const route = router.resolve({
109 name: "TicketAgent",
110 params: { ticketId: String(name) },
111 });
112 window.open(route.href, "_blank");
113}
114
115type SortValue = "all" | "latest" | "positive" | "negative";
116
117const sortOptions: { label: string; value: SortValue }[] = [
118 { label: __("All"), value: "all" },
119 { label: __("Latest"), value: "latest" },
120 { label: __("Positive"), value: "positive" },
121 { label: __("Negative"), value: "negative" },
122];
123
124const activeSort = ref<SortValue>("all");
125
126type FilterList = [string, string, unknown][];
127
128interface TabConfig {
129 filters: Record<string, unknown> | FilterList;
130 orderBy?: string;
131}
132
133function tabConfig(value: SortValue): TabConfig {
134 const base = { contact: props.name };
135 switch (value) {
136 case "all":
137 return { filters: { ...base, feedback_rating: ["is", "set"] } };
138 case "latest":
139 return {
140 filters: { ...base, feedback_rating: ["is", "set"] },
141 orderBy: "creation desc",
142 };
143 case "positive":
144 return {
145 filters: { ...base, feedback_rating: [">", 0.6] },
146 orderBy: "feedback_rating desc",
147 };
148 case "negative":
149 return {
150 filters: [
151 ["contact", "=", props.name],
152 ["feedback_rating", "is", "set"],
153 ["feedback_rating", "<=", 0.6],
154 ],
155 orderBy: "feedback_rating asc",
156 };
157 }
158}
159
160function onSortChange(value: SortValue) {
161 const { filters, orderBy } = tabConfig(value);
162 feedbackListResource.update({ filters, orderBy });
163 feedbackListResource.reload();
164}
165
166function formatRating(rating: number | undefined | null): number {
167 if (!rating) return 0;
168 return Number(rating) * 5;
169}
170
171function ratingBadgeClass(rating: number | undefined | null): string {
172 const stars = formatRating(rating);
173 if (stars >= 4) return "bg-surface-green-2 text-ink-green-6";
174 if (stars >= 2.5) return "bg-surface-yellow-2 text-ink-yellow-7";
175 return "bg-surface-red-2 text-ink-red-6";
176}
177
178onMounted(() => {
179 onSortChange(activeSort.value);
180});
181</script>
182