This commit is contained in:
Parsa Nazer
2025-02-24 20:11:16 +03:30
25 changed files with 662 additions and 283 deletions
+41 -27
View File
@@ -6,11 +6,19 @@ import { useToast } from "~/composables/global/useToast";
// types // types
type Props = { type Props = {
modelValue: File[]; modelValue: {
id: number;
file_link: string;
date: string;
size: number;
name: string;
}[];
loading?: boolean;
}; };
type Emits = { type Emits = {
"update:modelValue": [value: any]; "update:modelValue": [value: any];
change: [value: File];
}; };
// props // props
@@ -50,7 +58,8 @@ const onDrop = (files: File[] | null) => {
} else { } else {
if (modelValue.value.length + files.length <= 3) { if (modelValue.value.length + files.length <= 3) {
files?.forEach((item) => { files?.forEach((item) => {
emit("update:modelValue", [...modelValue.value, item]); emit("change", item);
resetFileDialog();
}); });
} else { } else {
addToast({ addToast({
@@ -78,22 +87,27 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
dataTypes: ["image/jpeg", "image/png", "image/jpg"], dataTypes: ["image/jpeg", "image/png", "image/jpg"],
}); });
const { open: openDialog, onChange } = useFileDialog({ const {
open: openDialog,
onChange,
reset: resetFileDialog,
} = useFileDialog({
accept: "image/*", accept: "image/*",
directory: false, directory: false,
}); });
onChange((files: any) => { onChange((files: any) => {
let arr: File[] = []; let arr: File[] = [];
Object.keys(files).forEach((item) => { Object.keys(files).forEach((_, index) => {
arr.push(files[item]); arr.push(files[index]);
}); });
onDrop(arr); onDrop(arr);
}); });
const deleteFile = (index: number) => { const removeAttachment = (id: number) => {
let target = modelValue.value.findIndex((i) => i.id == id);
const clone = [...modelValue.value]; const clone = [...modelValue.value];
clone.splice(index, 1); clone.splice(target, 1);
emit("update:modelValue", clone); emit("update:modelValue", clone);
}; };
</script> </script>
@@ -103,10 +117,22 @@ const deleteFile = (index: number) => {
<div <div
ref="dropZoneRef" ref="dropZoneRef"
@click="openDialog" @click="openDialog"
class="bg-slate-50 flex-col-center w-full transition-all text-black/50 gap-3 h-[20rem] border border-dashed rounded-xl cursor-pointer select-none" class="bg-slate-50 relative flex-col-center w-full transition-all text-black/50 gap-3 h-[20rem] border border-dashed rounded-xl cursor-pointer select-none"
:class="isOverDropZone ? 'border-black' : ' border-slate-300'" :class="{
'border-black': isOverDropZone,
' border-slate-300': !isOverDropZone,
'pointer-events-none': loading,
}"
> >
<Icon name="bi:file-earmark-arrow-down" size="32" /> <Icon
:name="
loading
? 'svg-spinners:ring-resize'
: 'bi:file-earmark-arrow-down'
"
size="32"
:class="loading ? '' : ''"
/>
<p class="font-bold text-dynamic-primary text-sm lg:text-[1rem]"> <p class="font-bold text-dynamic-primary text-sm lg:text-[1rem]">
برای آپلود کلیک کنید یا فایل خود را اینجا بیاندازید برای آپلود کلیک کنید یا فایل خود را اینجا بیاندازید
</p> </p>
@@ -121,24 +147,12 @@ const deleteFile = (index: number) => {
v-auto-animate v-auto-animate
class="flex flex-row-reverse items-center justify-between w-full px-2 animate__animated animate__fadeIn" class="flex flex-row-reverse items-center justify-between w-full px-2 animate__animated animate__fadeIn"
> >
<li class="w-full flex items-center"> <NewAttachment
<div class="flex justify-end w-9/12"> :id="item.id"
<p class="text-sm text-black">{{ item.name }}</p> :size="item.size"
</div> :name="item.name"
<div class="w-2/12"> @delete="removeAttachment"
<p class="text-sm text-black">
{{ (item.size / 1024).toFixed(2) }}KB
</p>
</div>
<div class="w-1/12">
<Icon
name="ci:close"
class="**:stroke-red-500 cursor-pointer pb-1"
@click="deleteFile(index)"
size="28"
/> />
</div>
</li>
</ul> </ul>
</div> </div>
</template> </template>
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// import // import
import type { ToastOptions } from "~/composables/global/useToast"; import type { ToastOptions } from "~/composables/global/useToast";
@@ -9,9 +8,9 @@ import { useToast } from "~/composables/global/useToast";
type Props = { type Props = {
id: number; id: number;
message: string, message: string;
options: ToastOptions options: ToastOptions;
} };
// props // props
@@ -24,7 +23,7 @@ const { destroyToast } = useToast();
const open = ref(true); const open = ref(true);
// method // methods
const onSwipeEnd = () => { const onSwipeEnd = () => {
setTimeout(() => { setTimeout(() => {
@@ -41,37 +40,39 @@ const statusIcon = computed(() => {
case "success": case "success":
return { return {
name: "duo-icons:check-circle", name: "duo-icons:check-circle",
class: "**:fill-success-500 [filter:drop-shadow(0_0_10px_var(--color-success-500))]" class: "**:fill-success-500 [filter:drop-shadow(0_0_10px_var(--color-success-500))]",
}; };
case "error": case "error":
return { return {
name: "duo-icons:alert-triangle", name: "duo-icons:alert-triangle",
class: "**:fill-danger-500 [filter:drop-shadow(0_0_10px_var(--color-danger-500))]" class: "**:fill-danger-500 [filter:drop-shadow(0_0_10px_var(--color-danger-500))]",
}; };
case "info": case "info":
return { return {
name: "duo-icons:info", name: "duo-icons:info",
class: "**:fill-cyan-500 [filter:drop-shadow(0_0_10px_var(--color-cyan-500))]" class: "**:fill-cyan-500 [filter:drop-shadow(0_0_10px_var(--color-cyan-500))]",
}; };
case "warning": case "warning":
return { return {
name: "duo-icons:alert-octagon", name: "duo-icons:alert-octagon",
class: "**:fill-warning-500 [filter:drop-shadow(0_0_10px_var(--color-warning-500))]" class: "**:fill-warning-500 [filter:drop-shadow(0_0_10px_var(--color-warning-500))]",
}; };
default: default:
return { return {
name: "duo-icons:info", name: "duo-icons:info",
class: "**:fill-slate-500 [filter:drop-shadow(0_0_10px_var(--color-slate-500))]" class: "**:fill-slate-500 [filter:drop-shadow(0_0_10px_var(--color-slate-500))]",
}; };
} }
}); });
// watch // watch
watch(() => open.value, (value) => { watch(
() => open.value,
(value) => {
if (!value) onSwipeEnd(); if (!value) onSwipeEnd();
}); }
);
// lifecycle // lifecycle
@@ -80,7 +81,6 @@ onMounted(() => {
open.value = false; open.value = false;
}, options.value.duration ?? 4000); }, options.value.duration ?? 4000);
}); });
</script> </script>
<template> <template>
@@ -1,11 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
// types // types
type Props = { type Props = {
selectedSlide: number; selectedSlide: number;
slides: ProductImage[] slides: ProductImage[];
} };
// props // props
@@ -23,17 +22,18 @@ const selectedSlideDetail = computed(() => {
})!; })!;
}); });
// method // methods
const changeSlide = (id: number) => { const changeSlide = (id: number) => {
emit("update:selectedSlide", id); emit("update:selectedSlide", id);
}; };
</script> </script>
<template> <template>
<div class="flex flex-col relative gap-6"> <div class="flex flex-col relative gap-6">
<div class="bg-white brightness-[97%] w-full relative aspect-square overflow-hidden rounded-200"> <div
class="bg-white brightness-[97%] w-full relative aspect-square overflow-hidden rounded-200"
>
<Transition name="zoom" mode="out-in"> <Transition name="zoom" mode="out-in">
<img <img
:key="selectedSlideDetail.id" :key="selectedSlideDetail.id"
@@ -47,7 +47,11 @@ const changeSlide = (id: number) => {
<div <div
@click="changeSlide(slide.id)" @click="changeSlide(slide.id)"
v-for="slide in slides" v-for="slide in slides"
:class="selectedSlide === slide.id ? '!ring-black' : 'ring-transparent'" :class="
selectedSlide === slide.id
? '!ring-black'
: 'ring-transparent'
"
class="active:scale-95 hover:ring-slate-200 transition-all cursor-pointer brightness-[97%] bg-white aspect-square ring-2 ring-offset-4 rounded-200 w-full overflow-hidden relative" class="active:scale-95 hover:ring-slate-200 transition-all cursor-pointer brightness-[97%] bg-white aspect-square ring-2 ring-offset-4 rounded-200 w-full overflow-hidden relative"
:key="slide.id" :key="slide.id"
> >
@@ -29,17 +29,17 @@ const {
isPending: isChatPending, isPending: isChatPending,
isFetchingNextPage: isNextChatPagePending, isFetchingNextPage: isNextChatPagePending,
hasNextPage: hasMoreChat, hasNextPage: hasMoreChat,
fetchNextPage: loadMoreChat fetchNextPage: loadMoreChat,
} = useGetChat(id, isOpen); } = useGetChat(id, isOpen);
const isCreateMessagePending = useIsMutating({ const isCreateMessagePending = useIsMutating({
mutationKey: [MUTATION_KEYS.create_chat] mutationKey: [MUTATION_KEYS.create_chat],
}); });
const canLoadMoreChat = ref(false); const canLoadMoreChat = ref(false);
const isChatScrollLocked = useScrollLock(chatContainerEl); const isChatScrollLocked = useScrollLock(chatContainerEl);
const { y: chatContainerScrollY } = useScroll(chatContainerEl, { const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
behavior: "smooth" behavior: "smooth",
}); });
useInfiniteScroll( useInfiniteScroll(
@@ -56,11 +56,11 @@ useInfiniteScroll(
distance: 10, distance: 10,
direction: "top", direction: "top",
throttle: 1000, throttle: 1000,
canLoadMore: () => canLoadMoreChat.value canLoadMore: () => canLoadMoreChat.value,
} }
); );
// method // methods
const scrollToBottom = () => { const scrollToBottom = () => {
chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0; chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0;
@@ -116,7 +116,7 @@ whenever(
}, 2000); }, 2000);
}, },
{ {
once: true once: true,
} }
); );
</script> </script>
@@ -131,7 +131,6 @@ whenever(
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<Transition name="zoom" mode="out-in"> <Transition name="zoom" mode="out-in">
<div <div
v-if="!isChatPending" v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4" class="p-4.5 h-full flex flex-col justify-between gap-4"
@@ -148,7 +147,10 @@ whenever(
v-if="hasMoreChat" v-if="hasMoreChat"
class="py-2 flex items-center justify-center" class="py-2 flex items-center justify-center"
> >
<Icon name="svg-spinners:3-dots-fade" size="24" /> <Icon
name="svg-spinners:3-dots-fade"
size="24"
/>
</div> </div>
<ChatMessage <ChatMessage
v-for="(message, index) in chatMessages" v-for="(message, index) in chatMessages"
@@ -173,7 +175,6 @@ whenever(
<ChatInput /> <ChatInput />
</div> </div>
<div <div
v-else v-else
class="w-full h-full flex items-center justify-center absolute inset-0" class="w-full h-full flex items-center justify-center absolute inset-0"
@@ -182,7 +183,10 @@ whenever(
</div> </div>
</Transition> </Transition>
</template> </template>
<div class="text-black p-4.5 size-full flex justify-center items-center" v-else> <div
class="text-black p-4.5 size-full flex justify-center items-center"
v-else
>
Please sign in first Please sign in first
</div> </div>
</div> </div>
@@ -1,24 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
// state // state
const isOpen = ref(false); const isOpen = ref(false);
// method // methods
const closeChat = () => isOpen.value = false; const closeChat = () => (isOpen.value = false);
// provide-inject // provide-inject
provide("isOpen", { provide("isOpen", {
isOpen, isOpen,
closeChat closeChat,
}); });
</script> </script>
<template> <template>
<button <button
v-if="!isOpen" v-if="!isOpen"
@click="isOpen = !isOpen" @click="isOpen = !isOpen"
@@ -32,5 +29,4 @@ provide("isOpen", {
</button> </button>
<ChatBoxContainer :isOpen="isOpen" /> <ChatBoxContainer :isOpen="isOpen" />
</template> </template>
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// types // types
import AiLoading from "~/components/product/ChatBox/AiLoading.vue"; import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
@@ -12,12 +11,12 @@ const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast(); const { addToast } = useToast();
const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient); const { mutateAsync: createMessage, isPending: isCreatingMessage } =
useCreateChatMessage(queryClient);
const chatInputEl = ref<HTMLInputElement | null>(null); const chatInputEl = ref<HTMLInputElement | null>(null);
// methods
// method
const sendMessage = async () => { const sendMessage = async () => {
const value = chatInputEl.value!.value; const value = chatInputEl.value!.value;
@@ -28,20 +27,18 @@ const sendMessage = async () => {
await createMessage({ await createMessage({
new_message: value, new_message: value,
productId: 1 productId: 1,
}); });
} catch (e) { } catch (e) {
addToast({ addToast({
message: "مشکلی پیش آمده", message: "مشکلی پیش آمده",
options: { options: {
status: "error" status: "error",
} },
}); });
} }
} }
}; };
</script> </script>
<template> <template>
@@ -71,12 +68,20 @@ const sendMessage = async () => {
<form <form
class="transition-all duration-200 relative flex items-center gap-4 w-full shadow-sm rounded-full h-[56px] border pe-3 ps-4" class="transition-all duration-200 relative flex items-center gap-4 w-full shadow-sm rounded-full h-[56px] border pe-3 ps-4"
:class="isCreatingMessage ? 'border-transparent shadow-black/10 bg-white/85 backdrop-blur-xl' : 'border-slate-200 shadow-transparent bg-white'" :class="
isCreatingMessage
? 'border-transparent shadow-black/10 bg-white/85 backdrop-blur-xl'
: 'border-slate-200 shadow-transparent bg-white'
"
> >
<input <input
ref="chatInputEl" ref="chatInputEl"
:disabled="isCreatingMessage" :disabled="isCreatingMessage"
:placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'" :placeholder="
isCreatingMessage
? 'دارم فکر میکنم...'
: 'سوال خود را بپرسید'
"
type="text" type="text"
name="text" name="text"
class="focus:outline-none h-full typo-p-sm w-full border-none" class="focus:outline-none h-full typo-p-sm w-full border-none"
@@ -89,13 +94,21 @@ const sendMessage = async () => {
:class="isCreatingMessage ? 'bg-transparent' : 'bg-black'" :class="isCreatingMessage ? 'bg-transparent' : 'bg-black'"
> >
<TransitionGroup name="fade-down"> <TransitionGroup name="fade-down">
<AiLoading v-if="isCreatingMessage" circle :size="75" class="mb-1" /> <AiLoading
<Icon v-else name="iconamoon:send-light" class="absolute rotate-180 **:stroke-white" /> v-if="isCreatingMessage"
circle
:size="75"
class="mb-1"
/>
<Icon
v-else
name="iconamoon:send-light"
class="absolute rotate-180 **:stroke-white"
/>
</TransitionGroup> </TransitionGroup>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</template> </template>
@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
// types // types
type Props = { type Props = {
id: number, id: number;
reverse?: boolean, reverse?: boolean;
content: string, content: string;
isLast?: boolean, isLast?: boolean;
loadingContent?: boolean loadingContent?: boolean;
} };
// props // props
@@ -23,20 +22,24 @@ const emit = defineEmits(["textUpdate"]);
const { $gsap: gsap } = useNuxtApp(); const { $gsap: gsap } = useNuxtApp();
// method // methods
const showMessage = () => { const showMessage = () => {
gsap.fromTo(`#message-container-${id.value}`, { gsap.fromTo(
`#message-container-${id.value}`,
{
rotateX: -50, rotateX: -50,
translateY: -40, translateY: -40,
opacity: 0 opacity: 0,
}, { },
{
rotateX: 0, rotateX: 0,
translateY: 0, translateY: 0,
opacity: 1, opacity: 1,
duration: 0.5, duration: 0.5,
ease: "expo.out" ease: "expo.out",
}); }
);
}; };
// lifecycle // lifecycle
@@ -47,19 +50,22 @@ onMounted(() => {
} }
if (reverse.value && isLast.value) { if (reverse.value && isLast.value) {
gsap.fromTo(`#chat-message-content-${id.value}`, { gsap.fromTo(
`#chat-message-content-${id.value}`,
{
text: "", text: "",
duration: 2.5, duration: 2.5,
ease: "none" ease: "none",
}, { },
{
text: { value: content.value, rtl: false }, text: { value: content.value, rtl: false },
duration: 2.5, duration: 2.5,
ease: "none", ease: "none",
onUpdate: () => emit("textUpdate") onUpdate: () => emit("textUpdate"),
}); }
);
} }
}); });
</script> </script>
<template> <template>
@@ -81,7 +87,11 @@ onMounted(() => {
</div> </div>
<div <div
class="rounded-150 px-4 py-3" class="rounded-150 px-4 py-3"
:class="reverse ? 'bg-slate-100 text-slate-600' : 'bg-black text-white'" :class="
reverse
? 'bg-slate-100 text-slate-600'
: 'bg-black text-white'
"
> >
<p <p
v-if="!loadingContent" v-if="!loadingContent"
@@ -91,11 +101,7 @@ onMounted(() => {
{{ content }} {{ content }}
</p> </p>
<Icon <Icon v-else name="svg-spinners:3-dots-bounce" size="20" />
v-else
name="svg-spinners:3-dots-bounce"
size="20"
/>
</div> </div>
</div> </div>
</div> </div>
+13 -15
View File
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// import // import
import useGetComments from "~/composables/api/product/useGetComments"; import useGetComments from "~/composables/api/product/useGetComments";
@@ -17,14 +16,15 @@ const { token } = useAuth();
const userComment = ref(""); const userComment = ref("");
const { data: comments, refetch: refetchComments } = useGetComments(id, page); const { data: comments, refetch: refetchComments } = useGetComments(id, page);
const { mutateAsync: createComment, isPending: isCreateCommentPending } = useCreateComment(id); const { mutateAsync: createComment, isPending: isCreateCommentPending } =
useCreateComment(id);
// method // methods
const submitComment = async () => { const submitComment = async () => {
if (userComment.value.length > 3) { if (userComment.value.length > 3) {
await createComment({ await createComment({
content: userComment.value content: userComment.value,
}); });
userComment.value = ""; userComment.value = "";
@@ -32,23 +32,25 @@ const submitComment = async () => {
await refetchComments(); await refetchComments();
} }
}; };
</script> </script>
<template> <template>
<section class="bg-slate-50"> <section class="bg-slate-50">
<div class="flex relative gap-8 my-42 container"> <div class="flex relative gap-8 my-42 container">
<div class="sticky top-0 flex flex-col gap-6 min-w-[400px] max-h-min bg-white p-8 rounded-xl border-[0.5px] border-slate-200"> <div
<h3 class="typo-h-3"> class="sticky top-0 flex flex-col gap-6 min-w-[400px] max-h-min bg-white p-8 rounded-xl border-[0.5px] border-slate-200"
نظرات کاربران >
</h3> <h3 class="typo-h-3">نظرات کاربران</h3>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Rating /> <Rating />
<span class="typo-p-sm"> <span class="typo-p-sm">
بر اساس {{ comments?.count }} نظر بر اساس {{ comments?.count }} نظر
</span> </span>
</div> </div>
<form @submit.prevent="submitComment" class="flex flex-col gap-6"> <form
@submit.prevent="submitComment"
class="flex flex-col gap-6"
>
<textarea <textarea
:disabled="!token" :disabled="!token"
class="w-full min-h-[200px] field-sizing-content rounded-xl bg-white p-4 border border-slate-200" class="w-full min-h-[200px] field-sizing-content rounded-xl bg-white p-4 border border-slate-200"
@@ -65,10 +67,7 @@ const submitComment = async () => {
نظر بنویسید نظر بنویسید
</Button> </Button>
<NuxtLink v-else to="/signin"> <NuxtLink v-else to="/signin">
<Button <Button type="button" class="rounded-full w-full">
type="button"
class="rounded-full w-full"
>
وارد شوید وارد شوید
</Button> </Button>
</NuxtLink> </NuxtLink>
@@ -90,7 +89,6 @@ const submitComment = async () => {
/> />
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
@@ -52,9 +52,10 @@ await suspense();
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="flex flex-col items-start gap-1"> <div class="flex flex-col items-start gap-1">
<span class="flex font-semibold text-dynamic-primary"> <span class="flex font-semibold text-dynamic-primary">
<p> <p v-if="account?.first_name">
{{ `${account?.first_name} ${account?.last_name}` }} {{ `${account?.first_name} ${account?.last_name}` }}
</p> </p>
<p v-else>بدون نام کاربری</p>
</span> </span>
<NuxtLink <NuxtLink
:to="{ name: 'profile' }" :to="{ name: 'profile' }"
@@ -0,0 +1,74 @@
<script setup lang="ts">
// imports
import useDeleteAttachment from "~/composables/api/tickets/useDeleteAttachment";
// types
type Props = {
id: number;
size: number;
name: string;
};
type Emits = {
delete: [value: number];
};
// props
defineProps<Props>();
// Emits
const emit = defineEmits<Emits>();
// queries
const { mutateAsync: deleteAttachment, isPending: deleteAttachmentPending } =
useDeleteAttachment();
const handleDeleteAttachment = (id: number) => {
deleteAttachment(
{ id },
{
onSuccess: () => {
emit("delete", id);
},
}
);
};
</script>
<template>
<li class="w-full flex items-center">
<div class="w-2/12 flex justify-start">
<p class="text-sm text-black">{{ (size / 1024).toFixed(2) }}KB</p>
</div>
<div class="flex justify-end w-9/12">
<p class="text-sm text-black">
{{ name }}
</p>
</div>
<div class="w-1/12 ps-1">
<button class="cursor-pointer" @click="handleDeleteAttachment(id)">
<Icon
:name="
deleteAttachmentPending
? 'svg-spinners:ring-resize'
: 'ci:close'
"
:class="
deleteAttachmentPending
? 'text-black/50'
: '**:stroke-red-500'
"
class="pt-1"
size="28"
/>
</button>
</div>
</li>
</template>
<style scoped></style>
@@ -5,17 +5,17 @@ import { API_ENDPOINTS } from "~/constants";
// types // types
export type CreateOrUpdateAddressRequest = Omit<Address, "is_main">; export type CreateOrUpdateAddressResponse = Omit<Address, "is_main">;
const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => { const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleCreateOrUpdateAddress = async ( const handleCreateOrUpdateAddress = async (
addressData: CreateOrUpdateAddressRequest addressData: CreateOrUpdateAddressResponse
) => { ) => {
const { data } = await axios[update.value ? "put" : "post"]( const { data } = await axios[update.value ? "put" : "post"](
update.value update.value
@@ -30,7 +30,7 @@ const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
}; };
return useMutation({ return useMutation({
mutationFn: (addressData: CreateOrUpdateAddressRequest) => mutationFn: (addressData: CreateOrUpdateAddressResponse) =>
handleCreateOrUpdateAddress(addressData), handleCreateOrUpdateAddress(addressData),
}); });
}; };
@@ -14,7 +14,7 @@ const useDeleteAddress = () => {
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleDeleteAddress = async (id: number) => { const handleDeleteAddress = async (id: number) => {
const { data } = await axios.delete( const { data } = await axios.delete(
@@ -20,7 +20,7 @@ const useUpdateAccount = () => {
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleUpdateAccount = async (params: UpdateAccountRequest) => { const handleUpdateAccount = async (params: UpdateAccountRequest) => {
const { data } = await axios.patch( const { data } = await axios.patch(
+9 -4
View File
@@ -1,11 +1,10 @@
export const useAuth = () => { export const useAuth = () => {
// state // state
const token = useCookie("token"); const token = useCookie("token");
const refreshToken = useCookie("refresh-token"); const refreshToken = useCookie("refresh-token");
// method // methods
const updateToken = (newToken: string) => { const updateToken = (newToken: string) => {
token.value = newToken; token.value = newToken;
@@ -25,6 +24,12 @@ export const useAuth = () => {
const isLoggedIn = computed(() => !!token.value); const isLoggedIn = computed(() => !!token.value);
return { token, refreshToken, updateRefreshToken, updateToken, logout, isLoggedIn }; return {
token,
refreshToken,
updateRefreshToken,
updateToken,
logout,
isLoggedIn,
};
}; };
@@ -6,17 +6,15 @@ import { API_ENDPOINTS } from "~/constants";
// types // types
export type RefreshAuthRequest = { export type RefreshAuthRequest = {
refresh: string, refresh: string;
}; };
export type RefreshAuthResponse = { export type RefreshAuthResponse = {
access: string, access: string;
refresh: string, refresh: string;
}; };
const useRefreshAuth = () => { const useRefreshAuth = () => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
@@ -24,12 +22,16 @@ const useRefreshAuth = () => {
// methods // methods
const handleRefreshAuth = async (variables: RefreshAuthRequest) => { const handleRefreshAuth = async (variables: RefreshAuthRequest) => {
const { data } = await axios.post<RefreshAuthResponse>(`${API_ENDPOINTS.auth.refresh}/`, variables); const { data } = await axios.post<RefreshAuthResponse>(
`${API_ENDPOINTS.auth.refresh}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationFn: (variables: RefreshAuthRequest) => handleRefreshAuth(variables) mutationFn: (variables: RefreshAuthRequest) =>
handleRefreshAuth(variables),
}); });
}; };
@@ -11,29 +11,37 @@ export type CreateChatMessageRequest = {
new_message: string; new_message: string;
}; };
export type CreateChatMessageResponse = Chat[] export type CreateChatMessageResponse = Chat[];
const useCreateChatMessage = (queryClient: QueryClient) => { const useCreateChatMessage = (queryClient: QueryClient) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleCreateChatMessage = async (variables: CreateChatMessageRequest) => { const handleCreateChatMessage = async (
variables: CreateChatMessageRequest
const { data } = await axios.post<CreateChatMessageResponse>(`${API_ENDPOINTS.chat.new_message}/${variables.productId}`, variables); ) => {
const { data } = await axios.post<CreateChatMessageResponse>(
`${API_ENDPOINTS.chat.new_message}/${variables.productId}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationKey: [MUTATION_KEYS.create_chat], mutationKey: [MUTATION_KEYS.create_chat],
mutationFn: (variables: CreateChatMessageRequest) => handleCreateChatMessage(variables), mutationFn: (variables: CreateChatMessageRequest) =>
handleCreateChatMessage(variables),
onMutate: (newMessage) => { onMutate: (newMessage) => {
const prevData = queryClient.getQueriesData({ queryKey: [QUERY_KEYS.chat] }); const prevData = queryClient.getQueriesData({
queryKey: [QUERY_KEYS.chat],
});
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => { queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>(
[QUERY_KEYS.chat],
(oldData) => {
const lastPage = oldData!.pages[oldData!.pages.length - 1]; const lastPage = oldData!.pages[oldData!.pages.length - 1];
return { return {
@@ -46,30 +54,32 @@ const useCreateChatMessage = (queryClient: QueryClient) => {
{ {
id: Date.now(), id: Date.now(),
content: newMessage.new_message, content: newMessage.new_message,
sender: "user" sender: "user",
}
]
}, },
...oldData!.pages ],
},
...oldData!.pages,
], ],
pageParams: [ pageParams: [
...oldData!.pageParams, ...oldData!.pageParams,
{ {
limit: 10, limit: 10,
offset: 0 offset: 0,
} },
] ],
}; };
}); }
);
return { prevData: prevData ? prevData[0][1] : undefined }; return { prevData: prevData ? prevData[0][1] : undefined };
}, },
onSuccess: (response) => { onSuccess: (response) => {
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>(
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => { [QUERY_KEYS.chat],
(oldData) => {
if (oldData) { if (oldData) {
const lastPage = oldData!.pages[oldData!.pages.length - 1]; const lastPage =
oldData!.pages[oldData!.pages.length - 1];
return { return {
pages: [ pages: [
@@ -79,36 +89,33 @@ const useCreateChatMessage = (queryClient: QueryClient) => {
previous: lastPage.previous, previous: lastPage.previous,
results: { results: {
...response[0], ...response[0],
id: Date.now() id: Date.now(),
}
}, },
...oldData!.pages },
...oldData!.pages,
], ],
pageParams: [ pageParams: [
...oldData!.pageParams, ...oldData!.pageParams,
{ {
limit: 10, limit: 10,
offset: 0 offset: 0,
} },
] ],
}; };
} }
return oldData; return oldData;
}); }
);
}, },
onError: (err, newMessage, context) => { onError: (err, newMessage, context) => {
if (context) { if (context) {
queryClient.setQueryData( queryClient.setQueryData([QUERY_KEYS.chat], context.prevData);
[QUERY_KEYS.chat],
context.prevData
);
} }
}, },
onSettled: (newMessage) => { onSettled: (newMessage) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] });
} },
}); });
}; };
+23 -20
View File
@@ -8,34 +8,36 @@ import { useAuth } from "~/composables/api/auth/useAuth";
export type GetBranchResponse = ApiPaginated<Chat>; export type GetBranchResponse = ApiPaginated<Chat>;
const useGetBranch = ( const useGetBranch = (productId: string | number, enabled: Ref<boolean>) => {
productId: string | number,
enabled: Ref<boolean>
) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
const { isLoggedIn } = useAuth(); const { isLoggedIn } = useAuth();
// method // methods
const handleGetChat = async ({ productId, limit, offset }: { const handleGetChat = async ({
productId: number | string, productId,
limit: number, limit,
offset: number offset,
}: {
productId: number | string;
limit: number;
offset: number;
}) => { }) => {
const { data } = await axios.get<GetBranchResponse>(
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.chat.messages}/${productId}`, { `${API_ENDPOINTS.chat.messages}/${productId}`,
{
params: { params: {
offset, offset,
limit limit,
}, },
headers: { headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo` Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`,
},
} }
}); );
return data; return data;
}; };
@@ -44,21 +46,22 @@ const useGetBranch = (
queryKey: [QUERY_KEYS.chat], queryKey: [QUERY_KEYS.chat],
initialPageParam: { initialPageParam: {
limit: 10, limit: 10,
offset: 0 offset: 0,
}, },
queryFn: ({ pageParam }) => handleGetChat({ queryFn: ({ pageParam }) =>
handleGetChat({
limit: pageParam.limit, limit: pageParam.limit,
offset: pageParam.offset, offset: pageParam.offset,
productId: productId productId: productId,
}), }),
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {
if (!lastPage.next) return undefined; if (!lastPage.next) return undefined;
return { return {
limit: 10, limit: 10,
offset: pages.length * 10 offset: pages.length * 10,
}; };
} },
}); });
}; };
@@ -6,24 +6,27 @@ import { API_ENDPOINTS } from "~/constants";
// types // types
export type CreateCommentRequest = { export type CreateCommentRequest = {
content: string content: string;
}; };
const useCreateComment = (id: number | string | undefined) => { const useCreateComment = (id: number | string | undefined) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
// method // methods
const handleCreateComment = async (variables: CreateCommentRequest) => { const handleCreateComment = async (variables: CreateCommentRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.product.create_comment}/${id}`, variables); const { data } = await axios.post(
`${API_ENDPOINTS.product.create_comment}/${id}`,
variables
);
return data; return data;
}; };
return useMutation({ return useMutation({
mutationFn: (variables: CreateCommentRequest) => handleCreateComment(variables) mutationFn: (variables: CreateCommentRequest) =>
handleCreateComment(variables),
}); });
}; };
@@ -0,0 +1,50 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type CreateTicketRequest = {
ticket_category: string | undefined;
order: number | undefined;
subject: string;
content: string;
attachments: {
id: number;
file_link: string;
date: string;
size: number;
name: string;
}[];
};
const useCreateTicket = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleCreateTicket = async (params: CreateTicketRequest) => {
const { data } = await axios.post(
API_ENDPOINTS.account.address.update,
{
...params,
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return data;
};
return useMutation({
mutationFn: (ticketData: CreateTicketRequest) =>
handleCreateTicket(ticketData),
});
};
export default useCreateTicket;
@@ -0,0 +1,36 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type DeleteAttachmentRequest = {
id: number | string;
};
// methods
export const handleDeleteAttachment = async ({
id,
}: DeleteAttachmentRequest) => {
// state
const { $axios: axios } = useNuxtApp();
const { data } = await axios.delete(
`${API_ENDPOINTS.tickets.delete_attachment}/${id}`
);
return data;
};
// composable
const useDeleteAttachment = () => {
return useMutation({
mutationFn: (data: DeleteAttachmentRequest) =>
handleDeleteAttachment({ ...data }),
});
};
export default useDeleteAttachment;
@@ -0,0 +1,52 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type UploadAttachmentRequest = {
file: File;
};
export type UploadAttachmentResponse = {
id: number;
file_link: string;
date: string;
size: number;
name: string;
};
// methods
export const handleUploadAttachment = async ({
file,
}: UploadAttachmentRequest) => {
// state
const { $axios: axios } = useNuxtApp();
const { data } = await axios.post<UploadAttachmentResponse>(
API_ENDPOINTS.tickets.upload_attachment,
{
file,
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return data;
};
// composable
const useUploadAttachment = () => {
return useMutation({
mutationFn: (data: UploadAttachmentRequest) =>
handleUploadAttachment({ file: data.file }),
});
};
export default useUploadAttachment;
+4 -1
View File
@@ -35,7 +35,10 @@ export const API_ENDPOINTS = {
categories: "/products/categories", categories: "/products/categories",
}, },
tickets: { tickets: {
get_all: "/tickets/", get_all: "/tickets",
create: "/tickets/create",
upload_attachment: "/tickets/attachment/create",
delete_attachment: "/tickets/attachment/delete",
}, },
}; };
+11 -3
View File
@@ -40,6 +40,8 @@ const tickets = computed(() => {
return data.value?.results.flat(); return data.value?.results.flat();
}); });
const hasTickets = computed(() => tickets.value?.length > 0);
const paginationData = computed(() => { const paginationData = computed(() => {
return tickets!.value?.results.map((_, i: number) => { return tickets!.value?.results.map((_, i: number) => {
return { type: "page", value: i }; return { type: "page", value: i };
@@ -54,7 +56,10 @@ const paginationData = computed(() => {
<div class="w-full flex flex-col gap-5"> <div class="w-full flex flex-col gap-5">
<div class="w-full flex items-center justify-between px-5"> <div class="w-full flex items-center justify-between px-5">
<div class="flex items-center justify-start gap-8"> <div class="flex items-center justify-start gap-8">
<div class="flex items-center justify-start gap-3"> <div
v-if="hasTickets"
class="flex items-center justify-start gap-3"
>
<span class="text-sm">ترتیب بر اساس</span> <span class="text-sm">ترتیب بر اساس</span>
<Select <Select
:options="['جدید ترین', 'قدیمی ترین']" :options="['جدید ترین', 'قدیمی ترین']"
@@ -63,7 +68,10 @@ const paginationData = computed(() => {
class="w-[5rem]" class="w-[5rem]"
/> />
</div> </div>
<div class="flex items-center justify-start gap-3"> <div
v-if="hasTickets"
class="flex items-center justify-start gap-3"
>
<span class="text-sm">وضعیت پرداخت</span> <span class="text-sm">وضعیت پرداخت</span>
<Select <Select
:options="[ :options="[
@@ -88,7 +96,7 @@ const paginationData = computed(() => {
</div> </div>
<Placeholder <Placeholder
v-if="!tickets?.length && !ticketsIsLoading" v-if="!hasTickets && !ticketsIsLoading"
class="!w-full !py-[5rem]" class="!w-full !py-[5rem]"
icon="bi:ticket" icon="bi:ticket"
title="تیکتی یافت نشد" title="تیکتی یافت نشد"
+106 -11
View File
@@ -1,4 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
// imports
import useCreateTicket, {
type CreateTicketRequest,
} from "~/composables/api/tickets/useCreateTicket";
import useUploadAttachment from "~/composables/api/tickets/useUploadAttachment";
import { useToast } from "~/composables/global/useToast";
import { QUERY_KEYS } from "~/constants";
// meta // meta
definePageMeta({ definePageMeta({
@@ -15,6 +24,12 @@ type TicketCategory = {
// state // state
const router = useRouter();
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const ticketCategories: TicketCategory[] = [ const ticketCategories: TicketCategory[] = [
{ {
title: "مالی و حسابداری", title: "مالی و حسابداری",
@@ -46,12 +61,76 @@ const ticketCategories: TicketCategory[] = [
}, },
]; ];
const ticketData = ref({ const ticketData = ref<CreateTicketRequest>({
category: undefined, ticket_category: undefined,
order: undefined, order: undefined,
message: "", subject: "",
files: [], content: "",
attachments: [],
}); });
// queries
const { mutateAsync: createTicket, isPending: createTicketIsPending } =
useCreateTicket();
const { mutate: uploadAttachment, isPending: uploadAttachmentIsPending } =
useUploadAttachment();
// methods
const handleUploadAttachment = (file: File) => {
uploadAttachment(
{ file },
{
onSuccess: (data) => {
ticketData.value.attachments.push({ ...data });
},
onError: (error) => {
addToast({
message: error.message
? error.message
: "خطایی در آپلود پیوست رخ داد",
options: {
status: "error",
description: "لطفا مجدد تلاش کنید",
},
});
},
}
);
};
const handleSubmit = () => {
createTicket(
{ ...ticketData.value },
{
onSuccess: () => {
router.push({ name: "profile-tickets" });
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.tickets],
});
addToast({
message: "تیکت شما با موفقیت ثبت شد",
options: {
status: "success",
description:
"پس از بررسی پشتیبانی به شما اطلاع رسانی می شود",
},
});
},
onError: () => {
addToast({
message: "خطایی در ثبت تیکت رخ داد",
options: {
status: "success",
description: "لطفا مجدد تلاش کنید",
},
});
},
}
);
};
</script> </script>
<template> <template>
@@ -75,7 +154,7 @@ const ticketData = ref({
<Select <Select
placeholder="انتخاب کنید" placeholder="انتخاب کنید"
variant="outlined" variant="outlined"
v-model="ticketData.category" v-model="ticketData.ticket_category"
> >
<template #content> <template #content>
<SelectGroup> <SelectGroup>
@@ -133,23 +212,39 @@ const ticketData = ref({
</template> </template>
</Select> </Select>
</DataField> </DataField>
<DataField
id="subject"
:required="true"
label="عنوان تیکت"
class="col-span-full"
>
<Input
v-model="ticketData.subject"
placeholder="عنوان تیکت را اینجا بنویسید ..."
variant="outlined"
/>
</DataField>
<DataField id="message" :required="true" label="متن تیکت"> <DataField id="message" :required="true" label="متن تیکت">
<textarea <textarea
v-model="ticketData.message" v-model="ticketData.content"
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" 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="متن تیکت را اینجا بنویسید ..." placeholder="متن تیکت را اینجا بنویسید ..."
/> />
</DataField> </DataField>
<FileInput v-model="ticketData.files" /> <FileInput
v-model="ticketData.attachments"
@change="handleUploadAttachment"
:loading="uploadAttachmentIsPending"
/>
</div> </div>
</ProfileSection> </ProfileSection>
<div class="w-full flex-center py-5"> <div class="w-full flex-center py-5">
<Button class="rounded-full px-20" end-icon="bi:send" size="md"> <Button class="rounded-full px-20" end-icon="bi:send" size="md">
<!-- <Icon <Icon
v-if="createAddressIsPending" v-if="createTicketIsPending"
name="svg-spinners:3-dots-bounce" name="svg-spinners:3-dots-bounce"
/> --> />
<span>ارسال تیکت</span> <span v-else>ارسال تیکت</span>
</Button> </Button>
</div> </div>
</div> </div>
+14 -9
View File
@@ -8,7 +8,9 @@ export default defineNuxtPlugin(() => {
const { token, logout } = useAuth(); const { token, logout } = useAuth();
const axios = axiosOriginal.create({ const axios = axiosOriginal.create({
baseURL: config.public.API_BASE_URL timeout: 30000,
timeoutErrorMessage: "فرآیند بیش از حد انتظار طول کشید",
baseURL: config.public.API_BASE_URL,
}); });
axios.interceptors.request.use((config) => { axios.interceptors.request.use((config) => {
@@ -16,16 +18,19 @@ export default defineNuxtPlugin(() => {
!config.url?.includes(API_ENDPOINTS.auth.signin) && !config.url?.includes(API_ENDPOINTS.auth.signin) &&
!config.url?.includes(API_ENDPOINTS.account.send_otp) !config.url?.includes(API_ENDPOINTS.account.send_otp)
) { ) {
config.headers.Authorization = token.value ? `Bearer ${token.value}` : undefined; config.headers.Authorization = token.value
? `Bearer ${token.value}`
: undefined;
} }
return config; return config;
}); });
axios.interceptors.response.use((response) => { axios.interceptors.response.use(
(response) => {
return response; return response;
}, async function(error) { },
async function (error) {
await Logger.axiosErrorLog(error); await Logger.axiosErrorLog(error);
// if (error.status === 401) { // if (error.status === 401) {
@@ -33,12 +38,12 @@ export default defineNuxtPlugin(() => {
// } // }
return Promise.reject(error); return Promise.reject(error);
}); }
);
return { return {
provide: { provide: {
axios axios,
} },
}; };
}); });