This commit is contained in:
Parsa Nazer
2025-03-02 16:39:49 +03:30
16 changed files with 541 additions and 152 deletions
+6 -1
View File
@@ -17,11 +17,16 @@ defineProps<Props>();
const params: any = inject("params");
const { y } = useWindowScroll({ behavior: "smooth" });
// computed
const page = computed({
get: () => (params?.page ? Number(params.page) : 1),
set: (value: number) => (params.page = value),
set: (value: number) => {
params.page = value;
y.value = 0;
},
});
</script>
@@ -0,0 +1,33 @@
<script setup lang="ts">
// types
type Props = {
index: number;
size: number;
link: string;
};
// props
defineProps<Props>();
</script>
<template>
<NuxtLink
:to="link"
target="_blank"
class="w-full flex items-center cursor-pointer p-2 gap-2 rounded-100 bg-slate-100 border border-slate-300"
>
<div class="size-10 flex-center border border-slate-300 rounded-md">
<Icon name="bi:pin-angle" class="**:fill-black" size="20" />
</div>
<div class="flex flex-col gap-1">
<span class="text-sm line-clamp-1">
{{ `فایل ضمیمه ${index}` }}
</span>
<span class="text-xs">{{ (size / 1024).toFixed(2) }}KB</span>
</div>
</NuxtLink>
</template>
<style scoped></style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
// imports
import useGetAccount from "~/composables/api/account/useGetAccount";
import { usePersianTimeAgo } from "~/composables/global/usePersianTimeAgo";
// types
type Props = {
is_user: boolean;
date: string;
message: string;
files: ServerFile[];
};
// props
const props = defineProps<Props>();
const { is_user, files, date } = toRefs(props);
// state
const timeAgo = usePersianTimeAgo(new Date(date.value));
// queries
const { data: account } = useGetAccount();
// computed
const profile = computed(() => {
return is_user.value
? account.value?.profile_photo
? account.value?.profile_photo
: "/avatars/1.jpg"
: "/avatars/3.jpg";
});
const username = computed(() => {
return is_user.value
? `${account.value?.first_name} ${account.value?.last_name}`
: "ادمین پشتیبانی هی ملز";
});
</script>
<template>
<div
class="w-full flex flex-col justify-center gap-2"
:class="is_user ? 'items-start' : 'items-end'"
>
<div
class="w-full lg:w-1/2 bg-slate-50 border border-slate-200 rounded-xl p-5 flex items-start"
:class="is_user ? 'rounded-br-none' : 'rounded-bl-none'"
>
<div class="w-2/12 flex items-start justify-start">
<img :src="profile" class="size-16 rounded-full" />
</div>
<div class="w-10/12 flex flex-col items-start pt-2">
<div class="flex flex-col gap-0.5 w-full">
<p
class="text-[10px] font-semibold text-[#1677FF] line-clamp-1 text-right"
style="direction: ltr"
>
{{ timeAgo }}
</p>
<p
class="text-xs font-semibold text-dynamic-secondary line-clamp-1"
>
{{ username }}
</p>
</div>
<div class="py-2 text-start">
{{ message }}
</div>
</div>
</div>
<div class="w-1/2 grid grid-cols-2 lg:grid-cols-3 gap-2">
<Attachment
v-for="(attachment, index) in files"
:index="index + 1"
:link="attachment.file_link"
:size="attachment.size"
/>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,25 @@
<script setup lang="ts">
// types
type Props = {
is_user: boolean;
};
// props
defineProps<Props>();
</script>
<template>
<div
class="w-full flex items-center"
:class="is_user ? 'justify-start' : 'justify-end'"
>
<Skeleton
class="!w-full lg:!w-1/2 !h-32 border border-slate-200 !rounded-xl p-5 flex items-start"
:class="is_user ? '!rounded-br-none' : '!rounded-bl-none'"
></Skeleton>
</div>
</template>
<style scoped></style>
@@ -1,4 +1,8 @@
<script setup lang="ts">
// imports
import { usePersianTimeAgo } from "~/composables/global/usePersianTimeAgo";
// types
type Props = {
@@ -7,7 +11,13 @@ type Props = {
// props
defineProps<Props>();
const props = defineProps<Props>();
const { data } = toRefs(props);
// computed
const timeAgo = usePersianTimeAgo(new Date(data.value.created_at));
</script>
<template>
@@ -18,11 +28,29 @@ defineProps<Props>();
scope="row"
class="w-3/12 px-6 py-6 font-medium whitespace-nowrap text-black"
>
{{ data.ticket_category }}
{{ data.ticket_category ? data.ticket_category : "--" }}
</td>
<td class="w-3/12 px-6 py-6">
{{ data.subject ? data.subject : "--" }}
</td>
<td class="w-3/12 px-6 py-6">
{{ data.created_at ? timeAgo : "--" }}
</td>
<td class="w-2/12 px-6 py-6">
<div
class="w-max rounded-full py-1.5 px-3 text-xs border"
:class="{
'text-warning-600 bg-warning-100 border-warning-600':
data.status == 'در انتظار پاسخ',
'text-success-600 bg-success-100 border-success-600':
data.status == 'پاسخ داده شده',
'text-danger-600 bg-danger-100 border-danger-600':
data.status == 'بسته شده',
}"
>
{{ data.status ? data.status : "--" }}
</div>
</td>
<td class="w-3/12 px-6 py-6">{{ data.subject }}</td>
<td class="w-3/12 px-6 py-6">{{ data.created_at }}</td>
<td class="w-2/12 px-6 py-6">{{ data.status }}</td>
<td class="w-1/12 px-6 py-6">
<NuxtLink
:to="{ name: 'profile-tickets-id', params: { id: data.id } }"
@@ -1,53 +0,0 @@
<script setup lang="ts">
// types
type Props = {
is_user: boolean;
date: string;
username: string;
message: string;
profile: string;
files: string[];
};
// props
defineProps<Props>();
</script>
<template>
<div
class="w-full flex items-center"
:class="is_user ? 'justify-start' : 'justify-end'"
>
<div
class="w-full lg:w-1/2 bg-slate-50 border border-slate-200 rounded-xl p-5 flex items-start"
:class="is_user ? 'rounded-br-none' : 'rounded-bl-none'"
>
<div class="w-2/12 flex items-start justify-start">
<img :src="profile" class="size-16 rounded-full" />
</div>
<div class="w-10/12 flex flex-col items-start pt-8">
<div class="flex flex-col gap-0.5 w-full">
<p
class="text-[10px] font-semibold text-[#1677FF] line-clamp-1 text-right"
style="direction: ltr"
>
{{ date }}
</p>
<p
class="text-xs font-semibold text-dynamic-secondary line-clamp-1"
>
{{ username }}
</p>
</div>
<div class="py-2 text-start">
{{ message }}
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,39 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type CreateMessageRequest = {
content: string;
attachments: ServerFile[];
ticket_id: number | string;
};
const useCreateMessage = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleCreateMessage = async (params: CreateMessageRequest) => {
const { data } = await axios.post(
API_ENDPOINTS.tickets.create_message,
{
content: params.content,
attachments: params.attachments.map((i) => i.id),
ticket: params.ticket_id,
}
);
return data;
};
return useMutation({
mutationFn: (messageData: CreateMessageRequest) =>
handleCreateMessage(messageData),
});
};
export default useCreateMessage;
@@ -7,7 +7,7 @@ import { API_ENDPOINTS } from "~/constants";
export type CreateTicketRequest = {
ticket_category: string | undefined;
order: number | undefined;
order_id: number | undefined;
subject: string;
content: string;
attachments: ServerFile[];
@@ -21,23 +21,15 @@ const useCreateTicket = () => {
// methods
const handleCreateTicket = async (params: CreateTicketRequest) => {
const { data } = await axios.post(
API_ENDPOINTS.tickets.create,
{
message: {
content: params.content,
attachments: params.attachments,
},
subject: params.subject,
ticket_category: params.ticket_category,
order: params.order,
const { data } = await axios.post(API_ENDPOINTS.tickets.create, {
message: {
content: params.content,
attachments: params.attachments.map((i) => i.id),
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
subject: params.subject,
ticket_category: params.ticket_category,
order_id: params.order_id,
});
return data;
};
@@ -0,0 +1,25 @@
// composables/usePersianTimeAgo.ts
import { ref, onMounted, onUnmounted } from "vue";
import { formatDistance, toDate } from "date-fns-jalali";
import { faIR } from "date-fns-jalali/locale";
export function usePersianTimeAgo(date: Date) {
const timeAgo = ref("");
const updateTimeAgo = () => {
timeAgo.value = formatDistance(toDate(date), new Date(), {
addSuffix: true,
locale: faIR,
});
};
onMounted(() => {
updateTimeAgo();
const interval = setInterval(updateTimeAgo, 60000);
onUnmounted(() => clearInterval(interval));
});
return timeAgo;
}
+1
View File
@@ -40,6 +40,7 @@ export const API_ENDPOINTS = {
upload_attachment: "/tickets/attachment/create",
delete_attachment: "/tickets/attachment/delete",
get_one: "/tickets",
create_message: "/tickets/message/create",
},
orders: {
get_all: "/order/list",
+14
View File
@@ -18,9 +18,11 @@
"@vueuse/nuxt": "^12.3.0",
"animate.css": "^4.1.1",
"axios": "^1.7.9",
"date-fns-jalali": "^4.1.0-0",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"isomorphic-dompurify": "^2.21.0",
"jalali-ts": "^8.0.0",
"masonry-layout": "^4.2.2",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
@@ -5860,6 +5862,12 @@
"node": ">=18"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/db0": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.2.1.tgz",
@@ -7536,6 +7544,12 @@
"integrity": "sha512-gE+YHWSbygYAoJa+Xg8LWxGILqFOxZSBQQw39ghel01fVFUxV7bjL0x1JFsHcLQ3uPjvn81HQMa+kxwyPWnxGQ==",
"license": "MIT"
},
"node_modules/jalali-ts": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/jalali-ts/-/jalali-ts-8.0.0.tgz",
"integrity": "sha512-XZmEjaw56w47ZjJUnC/18juoJta4BcpKRE3cFZpw07+gy+nt3b9e+KGqlcRFph8Xn4LRtyx6l5QpEZftbtDZ3Q==",
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
+2
View File
@@ -24,9 +24,11 @@
"@vueuse/nuxt": "^12.3.0",
"animate.css": "^4.1.1",
"axios": "^1.7.9",
"date-fns-jalali": "^4.1.0-0",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"isomorphic-dompurify": "^2.21.0",
"jalali-ts": "^8.0.0",
"masonry-layout": "^4.2.2",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
+254 -69
View File
@@ -1,5 +1,15 @@
<script setup lang="ts">
// imports
import useCreateMessage, {
type CreateMessageRequest,
} from "~/composables/api/tickets/useCreateMessage";
import useGetTicket from "~/composables/api/tickets/useGetTicket";
import useUploadAttachment from "~/composables/api/tickets/useUploadAttachment";
import { useToast } from "~/composables/global/useToast";
import useVuelidate from "@vuelidate/core";
import { helpers, required, minLength } from "@vuelidate/validators";
import { QUERY_KEYS } from "~/constants";
// meta
@@ -8,22 +18,140 @@ definePageMeta({
layout: "profile",
});
// state
const route = useRoute();
const ticketData = ref({
message: "",
files: [],
});
// computed
const ticketId = computed(() => route.params.id as string);
// state
const route = useRoute();
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const messagesContainerRefEl = ref<HTMLElement | null>(null);
const { y: messagesContainerRefElY } = useScroll(messagesContainerRefEl, {
behavior: "smooth",
});
const messageData = ref<CreateMessageRequest>({
content: "",
attachments: [],
ticket_id: ticketId.value,
});
// computed
const formRules = computed(() => {
return {
content: {
required: helpers.withMessage(
"فیلد متن تیکت الزامی می باشد",
required
),
minLength: helpers.withMessage(
"فیلد متن تیکت حداقل ۵ کرکتر می باشد",
minLength(5)
),
},
};
});
const formValidator$ = useVuelidate(formRules, messageData);
// queries
const { data: ticket, isLoading: ticketIsLoading } = useGetTicket(ticketId);
const { mutateAsync: uploadAttachment, isPending: uploadAttachmentIsPending } =
useUploadAttachment();
const { mutateAsync: createMessage, isPending: createMessageIsPending } =
useCreateMessage();
// methods
const handleAddNewMessageToMessages = async () => {
await ticket.value?.messages.push({
id: 0,
content: messageData.value.content,
is_user: true,
attachments: messageData.value.attachments,
created_at: Date.now().toString(),
});
};
const handleUploadAttachment = (file: File) => {
uploadAttachment(
{ file },
{
onSuccess: (data) => {
messageData.value.attachments.push({ ...data });
},
onError: (error) => {
addToast({
message: error.message
? error.message
: "خطایی در آپلود پیوست رخ داد",
options: {
status: "error",
description: "لطفا مجدد تلاش کنید",
},
});
},
}
);
};
const handleSubmit = async () => {
await formValidator$.value.$validate();
if (!formValidator$.value.$errors.length) {
handleAddNewMessageToMessages().then(() => {
setTimeout(() => {
messagesContainerRefElY.value =
messagesContainerRefEl.value?.scrollHeight!;
}, 500);
});
createMessage(
{ ...messageData.value },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.ticket],
});
addToast({
message: "پیام شما با موفقیت ثبت شد",
options: {
status: "success",
description:
"پس از بررسی پشتیبانی به شما اطلاع رسانی می شود",
},
});
messageData.value = {
content: "",
attachments: [],
ticket_id: ticketId.value,
};
formValidator$.value.$reset();
},
onError: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.ticket],
});
addToast({
message: "خطایی در ثبت پیام رخ داد",
options: {
status: "success",
description: "لطفا مجدد تلاش کنید",
},
});
},
}
);
}
};
</script>
<template>
@@ -34,38 +162,55 @@ const { data: ticket, isLoading: ticketIsLoading } = useGetTicket(ticketId);
class="flex flex-wrap items-center justify-between w-full py-4 border-b lg:px-5 border-slate-200"
>
<div
class="flex flex-col items-start w-1/2 gap-4 lg:gap-5 lg:w-full lg:items-center lg:flex-row"
class="flex flex-col items-start w-1/2 gap-4 lg:gap-5 lg:w-full lg:items-center lg:justify-between lg:flex-row"
>
<div
class="flex flex-col w-full gap-2 lg:py-2 lg:pe-5 lg:w-1/3 lg:border-e border-slate-200"
>
<p class="text-xs lg:text-sm text-dynamic-secondary">
دسته&zwnj;بندی :
</p>
<p class="text-xs font-semibold lg:text-sm text-black">
{{ ticket?.ticket_category }}
</p>
</div>
<div
class="flex flex-col w-full gap-2 lg:py-2 lg:pe-5 lg:w-1/3"
>
<p class="text-xs lg:text-sm text-dynamic-secondary">
وضعیت :
</p>
<p
class="text-xs font-semibold lg:text-sm text-black text-dynamic-secondary"
<div class="flex items-center w-full gap-4">
<div
class="flex flex-col w-full gap-2 lg:py-2 lg:pe-5 lg:w-1/3 lg:border-e border-slate-200"
>
{{ ticket?.status }}
</p>
</div>
<div class="flex flex-col w-full gap-2 lg:hidden border-black">
<p class="text-xs lg:text-sm text-dynamic-secondary">
موضوع :
</p>
<p class="text-xs font-semibold lg:text-sm text-black">
{{ ticket?.subject }}
</p>
<p class="text-xs lg:text-sm text-dynamic-secondary">
دسته&zwnj;بندی :
</p>
<Skeleton
v-if="ticketIsLoading"
class="!w-1/2 !h-5 !rounded-sm"
/>
<p
v-else
class="text-xs font-semibold lg:text-sm text-black"
>
{{ ticket?.ticket_category }}
</p>
</div>
<div
class="flex flex-col w-full gap-2 lg:py-2 lg:pe-5 lg:w-1/3"
>
<p class="text-xs lg:text-sm text-dynamic-secondary">
وضعیت :
</p>
<Skeleton
v-if="ticketIsLoading"
class="!w-1/2 !h-5 !rounded-sm"
/>
<p
v-else
class="text-xs font-semibold lg:text-sm text-black text-dynamic-secondary"
>
{{ ticket?.status }}
</p>
</div>
<div
class="flex flex-col w-full gap-2 lg:hidden border-black"
>
<p class="text-xs lg:text-sm text-dynamic-secondary">
موضوع :
</p>
<p class="text-xs font-semibold lg:text-sm text-black">
{{ ticket?.subject }}
</p>
</div>
</div>
<div class="items-center justify-end hidden w-1/4 lg:flex">
<NuxtLink :to="{ name: 'profile-tickets' }">
<Button
@@ -81,46 +226,86 @@ const { data: ticket, isLoading: ticketIsLoading } = useGetTicket(ticketId);
</div>
<div
class="hidden w-full text-sm px-5 pb-8 mt-3 border-b lg:block text-md border-slate-200"
class="hidden w-full text-sm px-5 pb-6 mt-1 border-b lg:block text-md border-slate-200"
>
<span class="text-black/50"> موضوع : </span>
{{ ticket?.subject }}
</div>
<div class="w-full flex flex-col gap-5 h-[32rem] overflow-y-auto">
<!-- <TicketBubble
v-for="(message, index) in messages"
:key="index"
:is_user="message.is_user"
:date="message.date"
:message="message.message"
:profile="message.profile"
:username="message.username"
:files="message.files"
/> -->
<Skeleton v-if="ticketIsLoading" class="!w-1/2 !h-5 !rounded-sm" />
<span v-else>
{{ ticket?.subject }}
</span>
</div>
<div
class="grid grid-cols-1 lg:grid-cols-2 gap-5 mt-5 pt-5 border-t border-slate-200"
ref="messagesContainerRefEl"
class="w-full flex flex-col gap-5 h-[32rem] overflow-y-auto"
>
<DataField id="message" :required="true" label="متن پیام">
<textarea
v-model="ticketData.message"
class="w-full bg-white border-[1.5px] border-slate-200 rounded-xl text-xs lg:text-sm p-5 text-black h-[10rem] lg:h-[20rem] transition resize-none outline-none focus:!border-black"
placeholder="متن تیکت را اینجا بنویسید ..."
<template v-if="ticketIsLoading">
<TicketBubbleLoading
v-for="i in 4"
:key="i"
:is_user="i % 2 != 0 ? true : false"
/>
</DataField>
<FileInput v-model="ticketData.files" />
</template>
<template v-else>
<TicketBubble
v-for="(message, index) in ticket?.messages"
:key="index"
:is_user="message.is_user"
:date="message.created_at"
:message="message.content"
:files="message.attachments"
/>
</template>
</div>
<div class="w-full flex-center py-5">
<Button class="rounded-full px-20" end-icon="bi:send" size="md">
<!-- <Icon
v-if="createAddressIsPending"
name="svg-spinners:3-dots-bounce"
/> -->
<span>ارسال پیام</span>
</Button>
<div
v-if="ticket?.status !== 'بسته شده' && !ticketIsLoading"
class="w-full flex flex-col gap-5"
>
<div
class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-5 border-t border-slate-200"
>
<DataField
id="message"
:required="true"
label="متن پیام"
:error="formValidator$.content"
>
<Textarea
v-model="messageData.content"
:error="formValidator$.content.$error"
class="h-[10rem] lg:h-[20rem]"
variant="outlined"
placeholder="متن پیام را اینجا بنویسید ..."
/>
</DataField>
<FileInput
v-model="messageData.attachments"
@change="handleUploadAttachment"
:loading="uploadAttachmentIsPending"
/>
</div>
<div class="w-full flex-center py-5">
<Button
@click="handleSubmit"
:loading="
createMessageIsPending || uploadAttachmentIsPending
"
class="rounded-full w-[14rem] h-11"
size="md"
>
<Icon
v-if="createMessageIsPending"
:name="
createMessageIsPending
? 'svg-spinners:3-dots-bounce'
: 'bi:send'
"
/>
<span v-else>ارسال پیام</span>
</Button>
</div>
</div>
</div>
</template>
+7 -6
View File
@@ -9,7 +9,7 @@ import useUploadAttachment from "~/composables/api/tickets/useUploadAttachment";
import { useToast } from "~/composables/global/useToast";
import { QUERY_KEYS } from "~/constants";
import useVuelidate from "@vuelidate/core";
import { helpers, required, minLength, email } from "@vuelidate/validators";
import { helpers, required, minLength } from "@vuelidate/validators";
// meta
@@ -66,7 +66,7 @@ const ticketCategories: TicketCategory[] = [
const ticketData = ref<CreateTicketRequest>({
ticket_category: undefined,
order: undefined,
order_id: undefined,
subject: "",
content: "",
attachments: [],
@@ -234,21 +234,21 @@ const handleSubmit = async () => {
<Select
placeholder="انتخاب کنید"
variant="outlined"
v-model="ticketData.order"
v-model="ticketData.order_id"
:loading="ordersIsLoading"
>
<template #trigger>
<SelectValue
:class="
ticketData.order
ticketData.order_id
? 'text-black'
: 'text-slate-400'
"
class="font-iran-yekan-x text-sm text-start placeholder-slate-400"
>
{{
ticketData.order
? `شماره سفارش : ${ticketData.order}`
ticketData.order_id
? `شماره سفارش : ${ticketData.order_id}`
: "وارد نشده"
}}
</SelectValue>
@@ -338,6 +338,7 @@ const handleSubmit = async () => {
<div class="w-full flex-center py-5">
<Button
@click="handleSubmit"
:loading="createTicketIsPending || uploadAttachmentIsPending"
class="rounded-full w-[14rem] h-11"
size="md"
>
+2 -1
View File
@@ -189,6 +189,7 @@ declare global {
type TicketMessage = {
id: number;
is_user: boolean;
content: string;
created_at: string;
attachments: ServerFile[];
@@ -199,7 +200,7 @@ declare global {
messages: TicketMessage[];
subject: string;
ticket_category: string;
status: string;
status: "در انتظار پاسخ" | "پاسخ داده شده" | "بسته شده";
created_at: string;
updated_at: string;
order: Order;