update frontend

This commit is contained in:
Parsa Nazer
2026-05-28 10:32:03 +03:30
parent 481452eea7
commit 35864e61dd
14 changed files with 473 additions and 35 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ const { date } = toRefs(props);
// state // state
const createdAt = usePersianTimeAgo(new Date(date.value)); const createdAt = usePersianTimeAgo(date.value);
</script> </script>
<template> <template>
@@ -2,6 +2,7 @@
// imports // imports
import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice"; import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice";
import usePersianDate from "~/composables/global/usePersianDate";
// types // types
@@ -15,6 +16,10 @@ const props = defineProps<Props>();
const { data } = toRefs(props); const { data } = toRefs(props);
// state
const { formatToPersian } = usePersianDate();
// queries // queries
const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.id)); const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.id));
@@ -30,7 +35,7 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
{{ data.order_id ? `${data.order_id}#` : "--" }} {{ data.order_id ? `${data.order_id}#` : "--" }}
</td> </td>
<td class="w-3/12 px-6 py-6 text-xs lg:text-sm font-medium whitespace-pre shrink-0"> <td class="w-3/12 px-6 py-6 text-xs lg:text-sm font-medium whitespace-pre shrink-0">
{{ data.created_at ?? "--" }} {{ data.created_at ? formatToPersian(data.created_at) : "--" }}
</td> </td>
<td class="w-2/12 px-6 py-6 text-xs lg:text-sm whitespace-pre shrink-0"> <td class="w-2/12 px-6 py-6 text-xs lg:text-sm whitespace-pre shrink-0">
{{ data.count ? data.count : "--" }} {{ data.count ? data.count : "--" }}
@@ -53,10 +58,25 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
</div> </div>
</td> </td>
<td class="w-1/12 px-6 py-6 shrink-0"> <td class="w-1/12 px-6 py-6 shrink-0">
<div class="flex items-center gap-2">
<NuxtLink :to="{ name: 'profile-purchases-and-orders-id', params: { id: data.id } }">
<button <button
class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md"
aria-label="مشاهده جزئیات سفارش"
>
<Icon
name="ci:eye-open"
class="**:stroke-black"
size="20"
/>
</button>
</NuxtLink>
<button
v-if="data.is_paid"
@click="!downloadIsLoading ? downloadFn() : undefined" @click="!downloadIsLoading ? downloadFn() : undefined"
:disabled="downloadIsLoading" :disabled="downloadIsLoading"
class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md" class="size-9 lg:size-10 flex-center border border-slate-200 rounded-md"
aria-label="دانلود فاکتور"
> >
<Icon <Icon
v-if="downloadIsLoading" v-if="downloadIsLoading"
@@ -71,6 +91,7 @@ const { downloadFn, downloadIsLoading } = useDownloadInvoice(String(data.value.i
size="20" size="20"
/> />
</button> </button>
</div>
</td> </td>
</tr> </tr>
</template> </template>
@@ -23,7 +23,10 @@
<Skeleton class="w-full !h-10 !rounded-sm" /> <Skeleton class="w-full !h-10 !rounded-sm" />
</td> </td>
<td class="w-1/12 px-6 py-6 shrink-0"> <td class="w-1/12 px-6 py-6 shrink-0">
<div class="flex items-center gap-2">
<Skeleton class="!size-10 !rounded-sm" /> <Skeleton class="!size-10 !rounded-sm" />
<Skeleton class="!size-10 !rounded-sm" />
</div>
</td> </td>
</tr> </tr>
</template> </template>
@@ -21,7 +21,7 @@ const { is_user, files, date } = toRefs(props);
// state // state
const timeAgo = usePersianTimeAgo(new Date(date.value)); const timeAgo = usePersianTimeAgo(date.value);
// queries // queries
@@ -17,8 +17,8 @@ const { data } = toRefs(props);
// computed // computed
const createdTimeAgo = usePersianTimeAgo(new Date(data.value.created_at)); const createdTimeAgo = usePersianTimeAgo(data.value.created_at);
const updatedTimeAgo = usePersianTimeAgo(new Date(data.value.updated_at)); const updatedTimeAgo = usePersianTimeAgo(data.value.updated_at);
</script> </script>
<template> <template>
@@ -26,7 +26,7 @@ const useGetAllOrders = () => {
const handleGetAllOrders = async () => { const handleGetAllOrders = async () => {
const { data } = await axios.get<GetAllOrdersResponse>(API_ENDPOINTS.orders.get_all, { const { data } = await axios.get<GetAllOrdersResponse>(API_ENDPOINTS.orders.get_all, {
params: { params: {
sort: sort.value ?? "created_at", sort: sort.value ?? "-created_at",
status: status.value, status: status.value,
offset: Number(page.value) * 10 - 10, offset: Number(page.value) * 10 - 10,
limit: 10, limit: 10,
@@ -0,0 +1,30 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import type { ComputedRef } from "vue";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetOrderResponse = OrderDetail;
const useGetOrder = (id: ComputedRef<string>) => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleGetOrder = async () => {
const { data } = await axios.get<GetOrderResponse>(`${API_ENDPOINTS.orders.get_one}/${id.value}`);
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.order, id],
queryFn: () => handleGetOrder(),
enabled: computed(() => !!id.value),
});
};
export default useGetOrder;
@@ -26,7 +26,7 @@ const useGetAllTickets = () => {
const handleGetAllTickets = async () => { const handleGetAllTickets = async () => {
const { data } = await axios.get<GetAllTicketsResponse>(API_ENDPOINTS.tickets.get_all, { const { data } = await axios.get<GetAllTicketsResponse>(API_ENDPOINTS.tickets.get_all, {
params: { params: {
sort: sort.value ?? "created_at", sort: sort.value ?? "-created_at",
filter: status.value, filter: status.value,
offset: Number(page.value) * 7 - 7, offset: Number(page.value) * 7 - 7,
limit: 7, limit: 7,
+15 -2
View File
@@ -1,11 +1,24 @@
// composables/usePersianDate.ts // composables/usePersianDate.ts
import { format, toDate } from "date-fns-jalali"; import { format } from "date-fns-jalali";
import { faIR } from "date-fns-jalali/locale"; import { faIR } from "date-fns-jalali/locale";
import { Jalali } from "jalali-ts";
export default function usePersianDate() { export default function usePersianDate() {
const formatToPersian = (isoDate: string): string => { const formatToPersian = (isoDate: string): string => {
try { try {
const date = toDate(new Date(isoDate)); const yearStr = isoDate.slice(0, 4);
const year = Number(yearStr);
// jDateTimeField from django_jalali sends Jalali years (13XX/14XX).
// Normal DateTimeField sends Gregorian (19XX/20XX). Detect by year
// and use jalali-ts to convert when needed — date-fns-jalali's
// parseISO doesn't actually do calendar conversion.
const date =
!Number.isNaN(year) && year < 1700
? Jalali.parse(isoDate).date
: new Date(isoDate);
if (isNaN(date.getTime())) return "Invalid date";
const persianDate = format(date, "yyyy/MM/dd", { locale: faIR }); const persianDate = format(date, "yyyy/MM/dd", { locale: faIR });
@@ -1,12 +1,44 @@
// composables/usePersianTimeAgo.ts // composables/usePersianTimeAgo.ts
import { formatDistance, toDate } from "date-fns-jalali"; import { formatDistance } from "date-fns-jalali";
import { faIR } from "date-fns-jalali/locale"; import { faIR } from "date-fns-jalali/locale";
import { Jalali } from "jalali-ts";
export function usePersianTimeAgo(date: Date) { const toGregorianDate = (input: Date | string): Date | null => {
if (input instanceof Date) {
return isNaN(input.getTime()) ? null : input;
}
if (typeof input !== "string" || !input) return null;
const yearStr = input.slice(0, 4);
const year = Number(yearStr);
// jDateTimeField from django_jalali serializes with the Jalali year
// (typically 13XX or 14XX). Anything below ~1700 is treated as Jalali
// and converted to the equivalent Gregorian moment via jalali-ts.
// date-fns-jalali's parseISO can't be used here — it parses the year
// numerically without calendar conversion.
if (!Number.isNaN(year) && year < 1700) {
try {
return Jalali.parse(input).date;
} catch {
return null;
}
}
const native = new Date(input);
return isNaN(native.getTime()) ? null : native;
};
export function usePersianTimeAgo(date: Date | string) {
const timeAgo = ref(""); const timeAgo = ref("");
const updateTimeAgo = () => { const updateTimeAgo = () => {
timeAgo.value = formatDistance(toDate(date), new Date(), { const parsed = toGregorianDate(date);
if (!parsed) {
timeAgo.value = "";
return;
}
timeAgo.value = formatDistance(parsed, new Date(), {
addSuffix: true, addSuffix: true,
locale: faIR, locale: faIR,
}); });
+2
View File
@@ -56,6 +56,7 @@ export const API_ENDPOINTS = {
}, },
orders: { orders: {
get_all: "/order/all", get_all: "/order/all",
get_one: "/order",
cart: { cart: {
download_invoice: "/order/invoice", download_invoice: "/order/invoice",
get_all: "/order/cart", get_all: "/order/cart",
@@ -94,6 +95,7 @@ export const QUERY_KEYS = {
tickets: "tickets", tickets: "tickets",
ticket: "ticket", ticket: "ticket",
orders: "orders", orders: "orders",
order: "order",
cart: "cart", cart: "cart",
transaction: "transaction", transaction: "transaction",
notifications: "notifications", notifications: "notifications",
@@ -0,0 +1,316 @@
<script setup lang="ts">
import useGetOrder from "~/composables/api/orders/useGetOrder";
import useDownloadInvoice from "~/composables/api/orders/useDownloadInvoice";
import usePersianDate from "~/composables/global/usePersianDate";
// meta
useSeoMeta({
title: "پنل کاربری جزئیات سفارش",
});
definePageMeta({
middleware: "check-is-logged-in",
layout: "profile",
});
// state
const route = useRoute();
const { formatToPersian } = usePersianDate();
// computed
const orderId = computed(() => route.params.id as string);
// queries
const { data: order, isLoading: orderIsLoading } = useGetOrder(orderId);
const { downloadFn, downloadIsLoading } = useDownloadInvoice(orderId.value);
// computed
const statusVariant = computed(() => {
const status = order.value?.status;
if (!status) return "neutral";
if (["ADMIN_PENDING", "PENDING"].includes(status)) return "warning";
if (["POSTED", "RECEIVED"].includes(status)) return "success";
if (["CANCELED", "REFUND", "REFUNDED"].includes(status)) return "danger";
return "neutral";
});
const statusClass = computed(() => {
return {
warning: "text-warning-600 bg-warning-100 border-warning-600",
success: "text-success-600 bg-success-100 border-success-600",
danger: "text-danger-600 bg-danger-100 border-danger-600",
neutral: "text-slate-600 bg-slate-100 border-slate-300",
}[statusVariant.value];
});
</script>
<template>
<div class="w-full flex flex-col gap-5">
<ProfilePageTitle
:title="`جزئیات سفارش ${order?.order_id ? `${order.order_id}#` : ''}`"
icon="ci:bi-cart"
/>
<div class="w-full flex flex-col gap-5 lg:px-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<NuxtLink :to="{ name: 'profile-purchases-and-orders' }">
<Button
end-icon="ci:bi-arrow-left"
size="md"
class="rounded-full"
>
<span class="whitespace-pre">بازگشت به سفارشات</span>
</Button>
</NuxtLink>
<Button
v-if="order?.is_paid"
@click="!downloadIsLoading ? downloadFn() : undefined"
:disabled="downloadIsLoading"
end-icon="ci:bi-download"
size="md"
class="rounded-full"
>
<span class="whitespace-pre">دانلود فاکتور</span>
</Button>
</div>
<div
v-if="orderIsLoading"
class="w-full grid grid-cols-1 lg:grid-cols-3 gap-5"
>
<Skeleton class="!w-full !h-40 !rounded-xl lg:col-span-2" />
<Skeleton class="!w-full !h-40 !rounded-xl" />
<Skeleton class="!w-full !h-60 !rounded-xl lg:col-span-2" />
<Skeleton class="!w-full !h-60 !rounded-xl" />
</div>
<Placeholder
v-else-if="!order"
class="!w-full !py-[5rem]"
icon="ci:bi-cart"
title="سفارشی یافت نشد"
/>
<div
v-else
class="w-full grid grid-cols-1 lg:grid-cols-3 gap-5"
>
<div
class="lg:col-span-2 w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50"
>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<Icon
name="ci:bi-cart"
class="**:fill-black"
size="20"
/>
<span class="text-sm font-semibold">اطلاعات سفارش</span>
</div>
<div
class="rounded-full py-1.5 px-3 text-xs border"
:class="statusClass"
>
{{ order.verbose_status ?? "--" }}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs lg:text-sm">
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">شماره سفارش:</span>
<span class="font-medium text-cyan-600">{{ order.order_id ? `${order.order_id}#` : "--" }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">تاریخ ثبت:</span>
<span class="font-medium">
{{ order.created_at ? formatToPersian(order.created_at) : "--" }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">تعداد اقلام:</span>
<span class="font-medium">{{ order.count ?? "--" }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">وضعیت پرداخت:</span>
<span class="font-medium">
{{ order.is_paid ? "پرداخت شده" : "پرداخت نشده" }}
</span>
</div>
</div>
</div>
<div
class="w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50"
>
<div class="flex items-center gap-3">
<Icon
name="ci:bi-map"
class="**:fill-black"
size="20"
/>
<span class="text-sm font-semibold">آدرس تحویل</span>
</div>
<div
v-if="order.address"
class="flex flex-col gap-2 text-xs lg:text-sm"
>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">گیرنده:</span>
<span class="font-medium">{{ order.address.name ?? "--" }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">شماره تماس:</span>
<span class="font-medium">{{ order.address.phone ?? "--" }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">استان / شهر:</span>
<span class="font-medium">
{{ order.address.province ?? "--" }} / {{ order.address.city ?? "--" }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-dynamic-secondary">کد پستی:</span>
<span class="font-medium">{{ order.address.postal_code ?? "--" }}</span>
</div>
<div class="text-dynamic-secondary leading-[180%]">
{{ order.address.address ?? "--" }}
</div>
</div>
<div
v-else
class="text-xs text-dynamic-secondary"
>
آدرسی ثبت نشده است
</div>
</div>
<div
class="lg:col-span-2 w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50"
>
<div class="flex items-center gap-3">
<Icon
name="ci:bi-box"
class="**:fill-black"
size="20"
/>
<span class="text-sm font-semibold">اقلام سفارش</span>
</div>
<Placeholder
v-if="!order.items?.length"
class="!w-full !py-10"
icon="ci:bi-box"
title="کالایی در این سفارش ثبت نشده"
/>
<ul
v-else
class="w-full flex flex-col divide-y divide-slate-200"
>
<li
v-for="item in order.items"
:key="item.id"
class="w-full flex items-center gap-3 py-4"
>
<NuxtImg
v-if="item.product?.image"
:src="item.product.image"
loading="lazy"
fetch-priority="low"
class="size-16 lg:size-20 rounded-100 border border-slate-200 object-cover"
/>
<div class="flex-1 flex flex-col gap-1">
<NuxtLink
v-if="item.product?.slug"
:to="`/product/${item.product.slug}`"
class="text-xs lg:text-sm font-semibold line-clamp-2 hover:text-cyan-600 transition-colors"
>
{{ item.product.title ?? "--" }}
</NuxtLink>
<span
v-else
class="text-xs lg:text-sm font-semibold line-clamp-2"
>
{{ item.product?.title ?? "--" }}
</span>
<span class="text-xs text-dynamic-secondary">
تعداد: {{ item.quantity }}
</span>
</div>
<div class="flex flex-col items-end gap-1 text-xs lg:text-sm">
<span class="font-medium whitespace-pre">{{ item.final_price }}</span>
<span
v-if="item.discount_percent && item.discount_percent > 0"
class="text-dynamic-secondary line-through whitespace-pre"
>
{{ item.price }}
</span>
</div>
</li>
</ul>
</div>
<div
class="w-full flex flex-col gap-4 p-5 border border-slate-200 rounded-xl bg-slate-50 h-fit"
>
<div class="flex items-center gap-3">
<Icon
name="ci:bi-tag"
class="**:fill-black"
size="20"
/>
<span class="text-sm font-semibold">خلاصه فاکتور</span>
</div>
<div class="flex flex-col gap-3 text-xs lg:text-sm">
<div
v-if="order.cart_total"
class="flex items-center justify-between gap-2 text-slate-800"
>
<span>جمع سبد:</span>
<span>{{ order.cart_total }}</span>
</div>
<div
v-if="order.discount_amount"
class="flex items-center justify-between gap-2 text-red-700"
>
<span>تخفیف:</span>
<span>{{ order.discount_amount }}</span>
</div>
<div
v-if="order.special_discount_total"
class="flex items-center justify-between gap-2 text-green-700"
>
<span>تخفیف ویژه:</span>
<span>{{ order.special_discount_total }}</span>
</div>
<div
v-if="order.tax"
class="flex items-center justify-between gap-2 text-slate-800"
>
<span>مالیات:</span>
<span>{{ order.tax }}</span>
</div>
<div
class="flex items-center justify-between gap-2 pt-3 border-t border-slate-200 text-slate-900 font-semibold"
>
<span>مبلغ نهایی:</span>
<span>{{ order.final_price ?? "--" }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
+4 -4
View File
@@ -26,11 +26,11 @@ const tableHeads = ref(["دسته بندی", "موضوع", "تاریخ ایجا
const sortFilters = ref([ const sortFilters = ref([
{ {
title: "جدید ترین", title: "جدید ترین",
value: "created_at", value: "-created_at",
}, },
{ {
title: "قدیمی ترین", title: "قدیمی ترین",
value: "-created_at", value: "created_at",
}, },
]); ]);
@@ -76,8 +76,8 @@ const paginationData = computed(() => {
// methods // methods
const clearFilters = () => { const clearFilters = () => {
sort.value = ""; sort.value = undefined;
status.value = ""; status.value = undefined;
}; };
</script> </script>
+21
View File
@@ -223,6 +223,27 @@ declare global {
special_discount_total?: string; special_discount_total?: string;
}; };
type OrderDetailItem = {
id: number;
product: CartItem["product"];
quantity: number;
price: string;
final_price: string;
discount: number;
discount_amount: string;
special_discount_amount: string | null;
discount_percent?: number;
};
type OrderDetail = Order & {
items: OrderDetailItem[];
address: Address | null;
tax: number | string | null;
cart_total: number | string | null;
discount_code: DiscountCode | null;
discount_amount: number | string | null;
};
type DiscountCode = { type DiscountCode = {
code: string; code: string;
percent: number; percent: number;