Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -6,11 +6,19 @@ import { useToast } from "~/composables/global/useToast";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
modelValue: File[];
|
||||
modelValue: {
|
||||
id: number;
|
||||
file_link: string;
|
||||
date: string;
|
||||
size: number;
|
||||
name: string;
|
||||
}[];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type Emits = {
|
||||
"update:modelValue": [value: any];
|
||||
change: [value: File];
|
||||
};
|
||||
|
||||
// props
|
||||
@@ -50,7 +58,8 @@ const onDrop = (files: File[] | null) => {
|
||||
} else {
|
||||
if (modelValue.value.length + files.length <= 3) {
|
||||
files?.forEach((item) => {
|
||||
emit("update:modelValue", [...modelValue.value, item]);
|
||||
emit("change", item);
|
||||
resetFileDialog();
|
||||
});
|
||||
} else {
|
||||
addToast({
|
||||
@@ -78,22 +87,27 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
dataTypes: ["image/jpeg", "image/png", "image/jpg"],
|
||||
});
|
||||
|
||||
const { open: openDialog, onChange } = useFileDialog({
|
||||
const {
|
||||
open: openDialog,
|
||||
onChange,
|
||||
reset: resetFileDialog,
|
||||
} = useFileDialog({
|
||||
accept: "image/*",
|
||||
directory: false,
|
||||
});
|
||||
|
||||
onChange((files: any) => {
|
||||
let arr: File[] = [];
|
||||
Object.keys(files).forEach((item) => {
|
||||
arr.push(files[item]);
|
||||
Object.keys(files).forEach((_, index) => {
|
||||
arr.push(files[index]);
|
||||
});
|
||||
onDrop(arr);
|
||||
});
|
||||
|
||||
const deleteFile = (index: number) => {
|
||||
const removeAttachment = (id: number) => {
|
||||
let target = modelValue.value.findIndex((i) => i.id == id);
|
||||
const clone = [...modelValue.value];
|
||||
clone.splice(index, 1);
|
||||
clone.splice(target, 1);
|
||||
emit("update:modelValue", clone);
|
||||
};
|
||||
</script>
|
||||
@@ -103,10 +117,22 @@ const deleteFile = (index: number) => {
|
||||
<div
|
||||
ref="dropZoneRef"
|
||||
@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="isOverDropZone ? 'border-black' : ' border-slate-300'"
|
||||
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="{
|
||||
'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>
|
||||
@@ -121,24 +147,12 @@ const deleteFile = (index: number) => {
|
||||
v-auto-animate
|
||||
class="flex flex-row-reverse items-center justify-between w-full px-2 animate__animated animate__fadeIn"
|
||||
>
|
||||
<li class="w-full flex items-center">
|
||||
<div class="flex justify-end w-9/12">
|
||||
<p class="text-sm text-black">{{ item.name }}</p>
|
||||
</div>
|
||||
<div class="w-2/12">
|
||||
<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>
|
||||
<NewAttachment
|
||||
:id="item.id"
|
||||
:size="item.size"
|
||||
:name="item.name"
|
||||
@delete="removeAttachment"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
@@ -16,18 +15,14 @@ defineProps<Props>();
|
||||
|
||||
// state
|
||||
|
||||
const params : any = inject("params");
|
||||
const params: any = inject("params");
|
||||
|
||||
const page = ref(params?.page ? Number(params.page) : 1);
|
||||
// computed
|
||||
|
||||
// watch
|
||||
|
||||
watch(
|
||||
() => page.value,
|
||||
(newPage) => {
|
||||
params.page = newPage;
|
||||
}
|
||||
);
|
||||
const page = computed({
|
||||
get: () => (params?.page ? Number(params.page) : 1),
|
||||
set: (value: number) => (params.page = value),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +42,11 @@ watch(
|
||||
<PaginationNext
|
||||
class="w-9 h-9 ml-4 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
|
||||
>
|
||||
<Icon name="ci:chevron-right" class="**:fill-back" size="18px" />
|
||||
<Icon
|
||||
name="ci:chevron-right"
|
||||
class="**:fill-back"
|
||||
size="18px"
|
||||
/>
|
||||
</PaginationNext>
|
||||
|
||||
<template v-for="(page, index) in items">
|
||||
@@ -81,4 +80,4 @@ watch(
|
||||
</PaginationFirst>
|
||||
</PaginationList>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,7 @@ defineProps<Props>();
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-100 flex-center"
|
||||
class="w-full flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-xl flex-center"
|
||||
>
|
||||
<Icon :name="icon" size="50" class="**:fill-gray-500" />
|
||||
<span class="text-lg text-gray-500"> {{ title }} </span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// import
|
||||
|
||||
import type { ToastOptions } from "~/composables/global/useToast";
|
||||
@@ -9,9 +8,9 @@ import { useToast } from "~/composables/global/useToast";
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
message: string,
|
||||
options: ToastOptions
|
||||
}
|
||||
message: string;
|
||||
options: ToastOptions;
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
@@ -24,7 +23,7 @@ const { destroyToast } = useToast();
|
||||
|
||||
const open = ref(true);
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const onSwipeEnd = () => {
|
||||
setTimeout(() => {
|
||||
@@ -41,37 +40,39 @@ const statusIcon = computed(() => {
|
||||
case "success":
|
||||
return {
|
||||
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":
|
||||
return {
|
||||
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":
|
||||
return {
|
||||
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":
|
||||
return {
|
||||
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:
|
||||
return {
|
||||
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(() => open.value, (value) => {
|
||||
if (!value) onSwipeEnd();
|
||||
});
|
||||
watch(
|
||||
() => open.value,
|
||||
(value) => {
|
||||
if (!value) onSwipeEnd();
|
||||
}
|
||||
);
|
||||
|
||||
// lifecycle
|
||||
|
||||
@@ -80,7 +81,6 @@ onMounted(() => {
|
||||
open.value = false;
|
||||
}, options.value.duration ?? 4000);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -92,7 +92,7 @@ onMounted(() => {
|
||||
:class="options.description ? 'rounded-150' : 'rounded-full'"
|
||||
>
|
||||
<ToastTitle
|
||||
:class="[ { 'mb-1.5' : options.description } ]"
|
||||
:class="[{ 'mb-1.5': options.description }]"
|
||||
class="[grid-area:_title] font-medium text-slate-600 text-sm flex items-center gap-2"
|
||||
>
|
||||
<Icon :name="statusIcon.name" :class="statusIcon.class" size="24" />
|
||||
@@ -106,4 +106,4 @@ onMounted(() => {
|
||||
</div>
|
||||
</ToastDescription>
|
||||
</ToastRoot>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
selectedSlide: number;
|
||||
slides: ProductImage[]
|
||||
}
|
||||
slides: ProductImage[];
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
@@ -23,17 +22,18 @@ const selectedSlideDetail = computed(() => {
|
||||
})!;
|
||||
});
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const changeSlide = (id: number) => {
|
||||
emit("update:selectedSlide", id);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<img
|
||||
:key="selectedSlideDetail.id"
|
||||
@@ -47,7 +47,11 @@ const changeSlide = (id: number) => {
|
||||
<div
|
||||
@click="changeSlide(slide.id)"
|
||||
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"
|
||||
:key="slide.id"
|
||||
>
|
||||
@@ -59,4 +63,4 @@ const changeSlide = (id: number) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -29,17 +29,17 @@ const {
|
||||
isPending: isChatPending,
|
||||
isFetchingNextPage: isNextChatPagePending,
|
||||
hasNextPage: hasMoreChat,
|
||||
fetchNextPage: loadMoreChat
|
||||
fetchNextPage: loadMoreChat,
|
||||
} = useGetChat(id, isOpen);
|
||||
const isCreateMessagePending = useIsMutating({
|
||||
mutationKey: [MUTATION_KEYS.create_chat]
|
||||
mutationKey: [MUTATION_KEYS.create_chat],
|
||||
});
|
||||
|
||||
const canLoadMoreChat = ref(false);
|
||||
|
||||
const isChatScrollLocked = useScrollLock(chatContainerEl);
|
||||
const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
|
||||
behavior: "smooth"
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
useInfiniteScroll(
|
||||
@@ -56,11 +56,11 @@ useInfiniteScroll(
|
||||
distance: 10,
|
||||
direction: "top",
|
||||
throttle: 1000,
|
||||
canLoadMore: () => canLoadMoreChat.value
|
||||
canLoadMore: () => canLoadMoreChat.value,
|
||||
}
|
||||
);
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const scrollToBottom = () => {
|
||||
chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0;
|
||||
@@ -116,7 +116,7 @@ whenever(
|
||||
}, 2000);
|
||||
},
|
||||
{
|
||||
once: true
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -131,16 +131,15 @@ whenever(
|
||||
|
||||
<template v-if="isLoggedIn">
|
||||
<Transition name="zoom" mode="out-in">
|
||||
|
||||
<div
|
||||
v-if="!isChatPending"
|
||||
class="p-4.5 h-full flex flex-col justify-between gap-4"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
maskImage:
|
||||
'linear-gradient(to top, transparent, black 5%, black, black)',
|
||||
}"
|
||||
maskImage:
|
||||
'linear-gradient(to top, transparent, black 5%, black, black)',
|
||||
}"
|
||||
class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto"
|
||||
ref="chatContainerEl"
|
||||
>
|
||||
@@ -148,7 +147,10 @@ whenever(
|
||||
v-if="hasMoreChat"
|
||||
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>
|
||||
<ChatMessage
|
||||
v-for="(message, index) in chatMessages"
|
||||
@@ -173,7 +175,6 @@ whenever(
|
||||
<ChatInput />
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center absolute inset-0"
|
||||
@@ -182,7 +183,10 @@ whenever(
|
||||
</div>
|
||||
</Transition>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// state
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const closeChat = () => isOpen.value = false;
|
||||
const closeChat = () => (isOpen.value = false);
|
||||
|
||||
// provide-inject
|
||||
|
||||
provide("isOpen", {
|
||||
isOpen,
|
||||
closeChat
|
||||
closeChat,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<button
|
||||
v-if="!isOpen"
|
||||
@click="isOpen = !isOpen"
|
||||
@@ -32,5 +29,4 @@ provide("isOpen", {
|
||||
</button>
|
||||
|
||||
<ChatBoxContainer :isOpen="isOpen" />
|
||||
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// types
|
||||
|
||||
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
|
||||
@@ -12,12 +11,12 @@ const { $queryClient: queryClient } = useNuxtApp();
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient);
|
||||
const { mutateAsync: createMessage, isPending: isCreatingMessage } =
|
||||
useCreateChatMessage(queryClient);
|
||||
|
||||
const chatInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const sendMessage = async () => {
|
||||
const value = chatInputEl.value!.value;
|
||||
@@ -28,20 +27,18 @@ const sendMessage = async () => {
|
||||
|
||||
await createMessage({
|
||||
new_message: value,
|
||||
productId: 1
|
||||
productId: 1,
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
addToast({
|
||||
message: "مشکلی پیش آمده",
|
||||
options: {
|
||||
status: "error"
|
||||
}
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,12 +68,20 @@ const sendMessage = async () => {
|
||||
|
||||
<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="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
|
||||
ref="chatInputEl"
|
||||
:disabled="isCreatingMessage"
|
||||
:placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'"
|
||||
:placeholder="
|
||||
isCreatingMessage
|
||||
? 'دارم فکر میکنم...'
|
||||
: 'سوال خود را بپرسید'
|
||||
"
|
||||
type="text"
|
||||
name="text"
|
||||
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'"
|
||||
>
|
||||
<TransitionGroup name="fade-down">
|
||||
<AiLoading v-if="isCreatingMessage" circle :size="75" class="mb-1" />
|
||||
<Icon v-else name="iconamoon:send-light" class="absolute rotate-180 **:stroke-white" />
|
||||
<AiLoading
|
||||
v-if="isCreatingMessage"
|
||||
circle
|
||||
:size="75"
|
||||
class="mb-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
name="iconamoon:send-light"
|
||||
class="absolute rotate-180 **:stroke-white"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -303,4 +316,4 @@ const sendMessage = async () => {
|
||||
transform: translate(-50%, -50%) rotate(443deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
id: number,
|
||||
reverse?: boolean,
|
||||
content: string,
|
||||
isLast?: boolean,
|
||||
loadingContent?: boolean
|
||||
}
|
||||
id: number;
|
||||
reverse?: boolean;
|
||||
content: string;
|
||||
isLast?: boolean;
|
||||
loadingContent?: boolean;
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
@@ -23,20 +22,24 @@ const emit = defineEmits(["textUpdate"]);
|
||||
|
||||
const { $gsap: gsap } = useNuxtApp();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const showMessage = () => {
|
||||
gsap.fromTo(`#message-container-${id.value}`, {
|
||||
rotateX: -50,
|
||||
translateY: -40,
|
||||
opacity: 0
|
||||
}, {
|
||||
rotateX: 0,
|
||||
translateY: 0,
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
ease: "expo.out"
|
||||
});
|
||||
gsap.fromTo(
|
||||
`#message-container-${id.value}`,
|
||||
{
|
||||
rotateX: -50,
|
||||
translateY: -40,
|
||||
opacity: 0,
|
||||
},
|
||||
{
|
||||
rotateX: 0,
|
||||
translateY: 0,
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
ease: "expo.out",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// lifecycle
|
||||
@@ -47,19 +50,22 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (reverse.value && isLast.value) {
|
||||
gsap.fromTo(`#chat-message-content-${id.value}`, {
|
||||
text: "",
|
||||
duration: 2.5,
|
||||
ease: "none"
|
||||
}, {
|
||||
text: { value: content.value, rtl: false },
|
||||
duration: 2.5,
|
||||
ease: "none",
|
||||
onUpdate: () => emit("textUpdate")
|
||||
});
|
||||
gsap.fromTo(
|
||||
`#chat-message-content-${id.value}`,
|
||||
{
|
||||
text: "",
|
||||
duration: 2.5,
|
||||
ease: "none",
|
||||
},
|
||||
{
|
||||
text: { value: content.value, rtl: false },
|
||||
duration: 2.5,
|
||||
ease: "none",
|
||||
onUpdate: () => emit("textUpdate"),
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,7 +87,11 @@ onMounted(() => {
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
v-if="!loadingContent"
|
||||
@@ -91,12 +101,8 @@ onMounted(() => {
|
||||
{{ content }}
|
||||
</p>
|
||||
|
||||
<Icon
|
||||
v-else
|
||||
name="svg-spinners:3-dots-bounce"
|
||||
size="20"
|
||||
/>
|
||||
<Icon v-else name="svg-spinners:3-dots-bounce" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// import
|
||||
|
||||
import useGetComments from "~/composables/api/product/useGetComments";
|
||||
@@ -16,15 +15,16 @@ const page = ref(1);
|
||||
const { token } = useAuth();
|
||||
const userComment = ref("");
|
||||
|
||||
const { data: comments, refetch : refetchComments } = useGetComments(id, page);
|
||||
const { mutateAsync: createComment, isPending: isCreateCommentPending } = useCreateComment(id);
|
||||
const { data: comments, refetch: refetchComments } = useGetComments(id, page);
|
||||
const { mutateAsync: createComment, isPending: isCreateCommentPending } =
|
||||
useCreateComment(id);
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const submitComment = async () => {
|
||||
if (userComment.value.length > 3) {
|
||||
await createComment({
|
||||
content: userComment.value
|
||||
content: userComment.value,
|
||||
});
|
||||
|
||||
userComment.value = "";
|
||||
@@ -32,23 +32,25 @@ const submitComment = async () => {
|
||||
await refetchComments();
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-slate-50">
|
||||
<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">
|
||||
<h3 class="typo-h-3">
|
||||
نظرات کاربران
|
||||
</h3>
|
||||
<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"
|
||||
>
|
||||
<h3 class="typo-h-3">نظرات کاربران</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Rating />
|
||||
<span class="typo-p-sm">
|
||||
بر اساس {{ comments?.count }} نظر
|
||||
</span>
|
||||
</div>
|
||||
<form @submit.prevent="submitComment" class="flex flex-col gap-6">
|
||||
<form
|
||||
@submit.prevent="submitComment"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<textarea
|
||||
:disabled="!token"
|
||||
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>
|
||||
<NuxtLink v-else to="/signin">
|
||||
<Button
|
||||
type="button"
|
||||
class="rounded-full w-full"
|
||||
>
|
||||
<Button type="button" class="rounded-full w-full">
|
||||
وارد شوید
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
@@ -90,7 +89,6 @@ const submitComment = async () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -52,17 +52,28 @@ await suspense();
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="flex font-semibold text-dynamic-primary">
|
||||
<p>
|
||||
<p v-if="account?.first_name">
|
||||
{{ `${account?.first_name} ${account?.last_name}` }}
|
||||
</p>
|
||||
<p v-else>بدون نام کاربری</p>
|
||||
</span>
|
||||
<button class="text-xs font-semibold text-cyan-500">
|
||||
<NuxtLink
|
||||
:to="{ name: 'profile' }"
|
||||
class="text-xs font-semibold text-cyan-500"
|
||||
>
|
||||
ویرایش اطلاعات
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<img
|
||||
src="https://shatelpart.com/storage/users/user-15-155Mn1.png"
|
||||
class="rounded-full size-[3rem] hover:border-dynamic-rose transition"
|
||||
<Avatar
|
||||
class="!size-[3rem]"
|
||||
:src="account!.profile_photo"
|
||||
:alt="
|
||||
account?.first_name && account?.last_name
|
||||
? `${account?.first_name.charAt(
|
||||
0
|
||||
)} ${account?.last_name.charAt(0)}`
|
||||
: 'بدون نام کاربری'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
data: Ticket;
|
||||
};
|
||||
|
||||
// props
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
@@ -8,13 +18,15 @@
|
||||
scope="row"
|
||||
class="w-3/12 px-6 py-6 font-medium whitespace-nowrap text-black"
|
||||
>
|
||||
Apple MacBook Pro 17
|
||||
{{ data.ticket_category }}
|
||||
</td>
|
||||
<td class="w-3/12 px-6 py-6">Silver</td>
|
||||
<td class="w-3/12 px-6 py-6">Laptop</td>
|
||||
<td class="w-2/12 px-6 py-6">$2999</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: 1 } }">
|
||||
<NuxtLink
|
||||
:to="{ name: 'profile-tickets-id', params: { id: data.id } }"
|
||||
>
|
||||
<button
|
||||
class="size-10 flex-center border border-slate-200 rounded-md"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
class="odd:bg-white even:bg-gray-50 last:border-none border-b border-slate-200"
|
||||
>
|
||||
<td
|
||||
scope="row"
|
||||
class="w-3/12 px-6 py-6 font-medium whitespace-nowrap text-black"
|
||||
>
|
||||
<Skeleton class="w-full !h-10 !rounded-sm" />
|
||||
</td>
|
||||
<td class="w-3/12 px-6 py-6">
|
||||
<Skeleton class="w-full !h-10 !rounded-sm" />
|
||||
</td>
|
||||
<td class="w-3/12 px-6 py-6">
|
||||
<Skeleton class="w-full !h-10 !rounded-sm" />
|
||||
</td>
|
||||
<td class="w-2/12 px-6 py-6">
|
||||
<Skeleton class="w-full !h-10 !rounded-sm" />
|
||||
</td>
|
||||
<td class="w-1/12 px-6 py-6">
|
||||
<Skeleton class="!size-10 !rounded-sm" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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
|
||||
|
||||
export type CreateOrUpdateAddressRequest = Omit<Address, "is_main">;
|
||||
export type CreateOrUpdateAddressResponse = Omit<Address, "is_main">;
|
||||
|
||||
const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const handleCreateOrUpdateAddress = async (
|
||||
addressData: CreateOrUpdateAddressRequest
|
||||
addressData: CreateOrUpdateAddressResponse
|
||||
) => {
|
||||
const { data } = await axios[update.value ? "put" : "post"](
|
||||
update.value
|
||||
@@ -30,7 +30,7 @@ const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (addressData: CreateOrUpdateAddressRequest) =>
|
||||
mutationFn: (addressData: CreateOrUpdateAddressResponse) =>
|
||||
handleCreateOrUpdateAddress(addressData),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const useDeleteAddress = () => {
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const handleDeleteAddress = async (id: number) => {
|
||||
const { data } = await axios.delete(
|
||||
|
||||
@@ -20,7 +20,7 @@ const useUpdateAccount = () => {
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const handleUpdateAccount = async (params: UpdateAccountRequest) => {
|
||||
const { data } = await axios.patch(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export const useAuth = () => {
|
||||
|
||||
// state
|
||||
|
||||
const token = useCookie("token");
|
||||
const refreshToken = useCookie("refresh-token");
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const updateToken = (newToken: string) => {
|
||||
token.value = newToken;
|
||||
@@ -15,7 +14,7 @@ export const useAuth = () => {
|
||||
refreshToken.value = newToken;
|
||||
};
|
||||
|
||||
const logout = (reload ?: boolean) => {
|
||||
const logout = (reload?: boolean) => {
|
||||
token.value = undefined;
|
||||
refreshToken.value = undefined;
|
||||
if (reload) window.location.reload();
|
||||
@@ -25,6 +24,12 @@ export const useAuth = () => {
|
||||
|
||||
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
|
||||
|
||||
export type RefreshAuthRequest = {
|
||||
refresh: string,
|
||||
refresh: string;
|
||||
};
|
||||
|
||||
export type RefreshAuthResponse = {
|
||||
access: string,
|
||||
refresh: string,
|
||||
access: string;
|
||||
refresh: string;
|
||||
};
|
||||
|
||||
|
||||
const useRefreshAuth = () => {
|
||||
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
@@ -24,12 +22,16 @@ const useRefreshAuth = () => {
|
||||
// methods
|
||||
|
||||
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 useMutation({
|
||||
mutationFn: (variables: RefreshAuthRequest) => handleRefreshAuth(variables)
|
||||
mutationFn: (variables: RefreshAuthRequest) =>
|
||||
handleRefreshAuth(variables),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,11 @@ export type SignInRequest = {
|
||||
};
|
||||
|
||||
export type SignInResponse = {
|
||||
access: string,
|
||||
refresh: string,
|
||||
access: string;
|
||||
refresh: string;
|
||||
};
|
||||
|
||||
|
||||
const useSignIn = () => {
|
||||
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
@@ -25,12 +23,15 @@ const useSignIn = () => {
|
||||
// methods
|
||||
|
||||
const handleSignIn = async (variables: SignInRequest) => {
|
||||
const { data } = await axios.post<SignInResponse>(`${API_ENDPOINTS.auth.signin}/`, variables);
|
||||
const { data } = await axios.post<SignInResponse>(
|
||||
`${API_ENDPOINTS.auth.signin}`,
|
||||
variables
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: SignInRequest) => handleSignIn(variables)
|
||||
mutationFn: (variables: SignInRequest) => handleSignIn(variables),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -11,64 +11,37 @@ export type CreateChatMessageRequest = {
|
||||
new_message: string;
|
||||
};
|
||||
|
||||
export type CreateChatMessageResponse = Chat[]
|
||||
export type CreateChatMessageResponse = Chat[];
|
||||
|
||||
const useCreateChatMessage = (queryClient: QueryClient) => {
|
||||
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const handleCreateChatMessage = async (variables: CreateChatMessageRequest) => {
|
||||
|
||||
const { data } = await axios.post<CreateChatMessageResponse>(`${API_ENDPOINTS.chat.new_message}/${variables.productId}`, variables);
|
||||
const handleCreateChatMessage = async (
|
||||
variables: CreateChatMessageRequest
|
||||
) => {
|
||||
const { data } = await axios.post<CreateChatMessageResponse>(
|
||||
`${API_ENDPOINTS.chat.new_message}/${variables.productId}`,
|
||||
variables
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [MUTATION_KEYS.create_chat],
|
||||
mutationFn: (variables: CreateChatMessageRequest) => handleCreateChatMessage(variables),
|
||||
mutationFn: (variables: CreateChatMessageRequest) =>
|
||||
handleCreateChatMessage(variables),
|
||||
onMutate: (newMessage) => {
|
||||
const prevData = queryClient.getQueriesData({ queryKey: [QUERY_KEYS.chat] });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => {
|
||||
const lastPage = oldData!.pages[oldData!.pages.length - 1];
|
||||
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
count: lastPage.count,
|
||||
next: lastPage.next,
|
||||
previous: lastPage.previous,
|
||||
results: [
|
||||
{
|
||||
id: Date.now(),
|
||||
content: newMessage.new_message,
|
||||
sender: "user"
|
||||
}
|
||||
]
|
||||
},
|
||||
...oldData!.pages
|
||||
],
|
||||
pageParams: [
|
||||
...oldData!.pageParams,
|
||||
{
|
||||
limit: 10,
|
||||
offset: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
const prevData = queryClient.getQueriesData({
|
||||
queryKey: [QUERY_KEYS.chat],
|
||||
});
|
||||
|
||||
|
||||
return { prevData: prevData ? prevData[0][1] : undefined };
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
|
||||
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => {
|
||||
if (oldData) {
|
||||
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>(
|
||||
[QUERY_KEYS.chat],
|
||||
(oldData) => {
|
||||
const lastPage = oldData!.pages[oldData!.pages.length - 1];
|
||||
|
||||
return {
|
||||
@@ -77,38 +50,72 @@ const useCreateChatMessage = (queryClient: QueryClient) => {
|
||||
count: lastPage.count,
|
||||
next: lastPage.next,
|
||||
previous: lastPage.previous,
|
||||
results: {
|
||||
...response[0],
|
||||
id: Date.now()
|
||||
}
|
||||
results: [
|
||||
{
|
||||
id: Date.now(),
|
||||
content: newMessage.new_message,
|
||||
sender: "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
...oldData!.pages
|
||||
...oldData!.pages,
|
||||
],
|
||||
pageParams: [
|
||||
...oldData!.pageParams,
|
||||
{
|
||||
limit: 10,
|
||||
offset: 0
|
||||
}
|
||||
]
|
||||
offset: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return oldData;
|
||||
});
|
||||
return { prevData: prevData ? prevData[0][1] : undefined };
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>(
|
||||
[QUERY_KEYS.chat],
|
||||
(oldData) => {
|
||||
if (oldData) {
|
||||
const lastPage =
|
||||
oldData!.pages[oldData!.pages.length - 1];
|
||||
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
count: lastPage.count,
|
||||
next: lastPage.next,
|
||||
previous: lastPage.previous,
|
||||
results: {
|
||||
...response[0],
|
||||
id: Date.now(),
|
||||
},
|
||||
},
|
||||
...oldData!.pages,
|
||||
],
|
||||
pageParams: [
|
||||
...oldData!.pageParams,
|
||||
{
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return oldData;
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (err, newMessage, context) => {
|
||||
if (context) {
|
||||
queryClient.setQueryData(
|
||||
[QUERY_KEYS.chat],
|
||||
context.prevData
|
||||
);
|
||||
queryClient.setQueryData([QUERY_KEYS.chat], context.prevData);
|
||||
}
|
||||
},
|
||||
onSettled: (newMessage) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -8,34 +8,36 @@ import { useAuth } from "~/composables/api/auth/useAuth";
|
||||
|
||||
export type GetBranchResponse = ApiPaginated<Chat>;
|
||||
|
||||
const useGetBranch = (
|
||||
productId: string | number,
|
||||
enabled: Ref<boolean>
|
||||
) => {
|
||||
|
||||
const useGetBranch = (productId: string | number, enabled: Ref<boolean>) => {
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
const handleGetChat = async ({ productId, limit, offset }: {
|
||||
productId: number | string,
|
||||
limit: number,
|
||||
offset: number
|
||||
const handleGetChat = async ({
|
||||
productId,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
productId: number | string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}) => {
|
||||
|
||||
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.chat.messages}/${productId}`, {
|
||||
params: {
|
||||
offset,
|
||||
limit
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`
|
||||
const { data } = await axios.get<GetBranchResponse>(
|
||||
`${API_ENDPOINTS.chat.messages}/${productId}`,
|
||||
{
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`,
|
||||
},
|
||||
}
|
||||
});
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -44,21 +46,22 @@ const useGetBranch = (
|
||||
queryKey: [QUERY_KEYS.chat],
|
||||
initialPageParam: {
|
||||
limit: 10,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
},
|
||||
queryFn: ({ pageParam }) => handleGetChat({
|
||||
limit: pageParam.limit,
|
||||
offset: pageParam.offset,
|
||||
productId: productId
|
||||
}),
|
||||
queryFn: ({ pageParam }) =>
|
||||
handleGetChat({
|
||||
limit: pageParam.limit,
|
||||
offset: pageParam.offset,
|
||||
productId: productId,
|
||||
}),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage.next) return undefined;
|
||||
|
||||
return {
|
||||
limit: 10,
|
||||
offset: pages.length * 10
|
||||
offset: pages.length * 10,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,24 +6,27 @@ import { API_ENDPOINTS } from "~/constants";
|
||||
// types
|
||||
|
||||
export type CreateCommentRequest = {
|
||||
content: string
|
||||
content: string;
|
||||
};
|
||||
|
||||
const useCreateComment = (id: number | string | undefined) => {
|
||||
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// method
|
||||
// methods
|
||||
|
||||
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 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,44 @@
|
||||
// imports
|
||||
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
|
||||
|
||||
// types
|
||||
|
||||
export type GetAllTicketsResponse = ApiPaginated<Ticket>;
|
||||
|
||||
export type GetAllTicketsFilters = {
|
||||
sort: string | undefined;
|
||||
filter: string | undefined;
|
||||
page: string | string[];
|
||||
};
|
||||
|
||||
const useGetAllTickets = (params: Ref<GetAllTicketsFilters>) => {
|
||||
// state
|
||||
|
||||
const { $axios: axios } = useNuxtApp();
|
||||
|
||||
// methods
|
||||
|
||||
const handleGetAllTickets = async (params: GetAllTicketsFilters) => {
|
||||
const { data } = await axios.get<GetAllTicketsResponse>(
|
||||
API_ENDPOINTS.tickets.get_all,
|
||||
{
|
||||
params: {
|
||||
sort: params.sort,
|
||||
filter: params.filter,
|
||||
offset: Number(params.page) * 10 - 10,
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.tickets, params],
|
||||
queryFn: () => handleGetAllTickets(params.value),
|
||||
});
|
||||
};
|
||||
|
||||
export default useGetAllTickets;
|
||||
@@ -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;
|
||||
@@ -34,6 +34,12 @@ export const API_ENDPOINTS = {
|
||||
get_all: "/products",
|
||||
categories: "/products/categories",
|
||||
},
|
||||
tickets: {
|
||||
get_all: "/tickets",
|
||||
create: "/tickets/create",
|
||||
upload_attachment: "/tickets/attachment/create",
|
||||
delete_attachment: "/tickets/attachment/delete",
|
||||
},
|
||||
};
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
@@ -47,6 +53,7 @@ export const QUERY_KEYS = {
|
||||
account: "account",
|
||||
categories: "categories",
|
||||
addresses: "addresses",
|
||||
tickets: "tickets",
|
||||
};
|
||||
|
||||
export const MUTATION_KEYS = {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// imports
|
||||
|
||||
import useGetAllTickets, {
|
||||
type GetAllTicketsFilters,
|
||||
} from "~/composables/api/tickets/useGetAllTickets";
|
||||
|
||||
// meta
|
||||
|
||||
definePageMeta({
|
||||
@@ -8,10 +14,12 @@ definePageMeta({
|
||||
|
||||
// state
|
||||
|
||||
const filters = ref({
|
||||
const params = useUrlSearchParams();
|
||||
|
||||
const filters = ref<GetAllTicketsFilters>({
|
||||
sort: undefined,
|
||||
status: undefined,
|
||||
search: "",
|
||||
filter: undefined,
|
||||
page: params.page ?? 1,
|
||||
});
|
||||
|
||||
const tableHeads = ref([
|
||||
@@ -21,6 +29,24 @@ const tableHeads = ref([
|
||||
"وضعیت",
|
||||
"عملیات",
|
||||
]);
|
||||
|
||||
// queries
|
||||
|
||||
const { data, isLoading: ticketsIsLoading } = useGetAllTickets(filters);
|
||||
|
||||
// computed
|
||||
|
||||
const tickets = computed(() => {
|
||||
return data.value?.results.flat();
|
||||
});
|
||||
|
||||
const hasTickets = computed(() => tickets.value?.length > 0);
|
||||
|
||||
const paginationData = computed(() => {
|
||||
return tickets!.value?.results.map((_, i: number) => {
|
||||
return { type: "page", value: i };
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,7 +56,10 @@ const tableHeads = ref([
|
||||
<div class="w-full flex flex-col gap-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-3">
|
||||
<div
|
||||
v-if="hasTickets"
|
||||
class="flex items-center justify-start gap-3"
|
||||
>
|
||||
<span class="text-sm">ترتیب بر اساس</span>
|
||||
<Select
|
||||
:options="['جدید ترین', 'قدیمی ترین']"
|
||||
@@ -39,7 +68,10 @@ const tableHeads = ref([
|
||||
class="w-[5rem]"
|
||||
/>
|
||||
</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>
|
||||
<Select
|
||||
:options="[
|
||||
@@ -47,7 +79,7 @@ const tableHeads = ref([
|
||||
'در حال پردازش',
|
||||
'لغو شده',
|
||||
]"
|
||||
v-model="filters.status!"
|
||||
v-model="filters.filter!"
|
||||
triggerRootClass="!py-2.5"
|
||||
class="w-[5rem]"
|
||||
/>
|
||||
@@ -63,7 +95,14 @@ const tableHeads = ref([
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Placeholder
|
||||
v-if="!hasTickets && !ticketsIsLoading"
|
||||
class="!w-full !py-[5rem]"
|
||||
icon="bi:ticket"
|
||||
title="تیکتی یافت نشد"
|
||||
/>
|
||||
|
||||
<Table v-else>
|
||||
<template #thead>
|
||||
<th
|
||||
v-for="(tableHead, index) in tableHeads"
|
||||
@@ -82,9 +121,22 @@ const tableHeads = ref([
|
||||
</th>
|
||||
</template>
|
||||
<template #tbody>
|
||||
<TicketsTableRow v-for="i in 4" />
|
||||
<template v-if="ticketsIsLoading">
|
||||
<TicketsTableRowLoading v-for="i in 5" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<TicketsTableRow
|
||||
v-for="(ticket, index) in tickets"
|
||||
:key="index"
|
||||
:data="ticket"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div v-if="tickets?.length > 10" class="w-full flex-center py-10">
|
||||
<Pagination :items="paginationData" :total="data?.count" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<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
|
||||
|
||||
definePageMeta({
|
||||
@@ -15,6 +24,12 @@ type TicketCategory = {
|
||||
|
||||
// state
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { $queryClient: queryClient } = useNuxtApp();
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
const ticketCategories: TicketCategory[] = [
|
||||
{
|
||||
title: "مالی و حسابداری",
|
||||
@@ -46,12 +61,76 @@ const ticketCategories: TicketCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const ticketData = ref({
|
||||
category: undefined,
|
||||
const ticketData = ref<CreateTicketRequest>({
|
||||
ticket_category: undefined,
|
||||
order: undefined,
|
||||
message: "",
|
||||
files: [],
|
||||
subject: "",
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -75,7 +154,7 @@ const ticketData = ref({
|
||||
<Select
|
||||
placeholder="انتخاب کنید"
|
||||
variant="outlined"
|
||||
v-model="ticketData.category"
|
||||
v-model="ticketData.ticket_category"
|
||||
>
|
||||
<template #content>
|
||||
<SelectGroup>
|
||||
@@ -133,23 +212,39 @@ const ticketData = ref({
|
||||
</template>
|
||||
</Select>
|
||||
</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="متن تیکت">
|
||||
<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"
|
||||
placeholder="متن تیکت را اینجا بنویسید ..."
|
||||
/>
|
||||
</DataField>
|
||||
<FileInput v-model="ticketData.files" />
|
||||
<FileInput
|
||||
v-model="ticketData.attachments"
|
||||
@change="handleUploadAttachment"
|
||||
:loading="uploadAttachmentIsPending"
|
||||
/>
|
||||
</div>
|
||||
</ProfileSection>
|
||||
<div class="w-full flex-center py-5">
|
||||
<Button class="rounded-full px-20" end-icon="bi:send" size="md">
|
||||
<!-- <Icon
|
||||
v-if="createAddressIsPending"
|
||||
<Icon
|
||||
v-if="createTicketIsPending"
|
||||
name="svg-spinners:3-dots-bounce"
|
||||
/> -->
|
||||
<span>ارسال تیکت</span>
|
||||
/>
|
||||
<span v-else>ارسال تیکت</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
-16
@@ -8,7 +8,9 @@ export default defineNuxtPlugin(() => {
|
||||
const { token, logout } = useAuth();
|
||||
|
||||
const axios = axiosOriginal.create({
|
||||
baseURL: config.public.API_BASE_URL
|
||||
timeout: 30000,
|
||||
timeoutErrorMessage: "فرآیند بیش از حد انتظار طول کشید",
|
||||
baseURL: config.public.API_BASE_URL,
|
||||
});
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
@@ -16,29 +18,32 @@ export default defineNuxtPlugin(() => {
|
||||
!config.url?.includes(API_ENDPOINTS.auth.signin) &&
|
||||
!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;
|
||||
});
|
||||
|
||||
axios.interceptors.response.use((response) => {
|
||||
return response;
|
||||
}, async function(error) {
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async function (error) {
|
||||
await Logger.axiosErrorLog(error);
|
||||
|
||||
await Logger.axiosErrorLog(error);
|
||||
// if (error.status === 401) {
|
||||
// logout();
|
||||
// }
|
||||
|
||||
// if (error.status === 401) {
|
||||
// logout();
|
||||
// }
|
||||
|
||||
return Promise.reject(error);
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
provide: {
|
||||
axios
|
||||
}
|
||||
axios,
|
||||
},
|
||||
};
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+9
@@ -165,4 +165,13 @@ declare global {
|
||||
iconClass?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type Ticket = {
|
||||
id: number;
|
||||
subject: string;
|
||||
ticket_category: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user