diff --git a/public/server-error-icon.svg b/public/server-error-icon.svg new file mode 100644 index 0000000..e3a5791 --- /dev/null +++ b/public/server-error-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/(customer)/(shop)/orders/(routes)/(landing)/page.tsx b/src/app/(customer)/(shop)/orders/(routes)/(landing)/page.tsx new file mode 100644 index 0000000..534c2ec --- /dev/null +++ b/src/app/(customer)/(shop)/orders/(routes)/(landing)/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useOrders } from "@/lib/providers/orders-provider"; +import OrderItem from "@/components/orders/order-item"; +import OrderEmpty from "@/components/orders/order-empty"; +import OrderServerError from "@/components/orders/order-server-error"; +import OrdersSkeleton from "@/components/orders/orders-skeleton"; + +export default function OrdersPage() { + const { data, error, isLoading } = useOrders(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + if (!data || data.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {data.map((group) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/app/(customer)/(shop)/orders/(routes)/[orderId]/page.tsx b/src/app/(customer)/(shop)/orders/(routes)/[orderId]/page.tsx new file mode 100644 index 0000000..df4adf9 --- /dev/null +++ b/src/app/(customer)/(shop)/orders/(routes)/[orderId]/page.tsx @@ -0,0 +1,29 @@ +import { requireSession } from "@/lib/auth/session"; +import { getOrderGroup } from "@/lib/api/orders"; +import { OrderDetailProvider } from "@/lib/providers/order-detail-provider"; +import OrderDetailContent from "@/components/orders/order-detail-content"; + +interface Props { + params: Promise<{ orderId: string }>; +} + +export default async function OrderDetailPage({ params }: Props) { + await requireSession("/login"); + + const { orderId } = await params; + + let orderGroup = null; + let error: string | null = null; + + try { + orderGroup = await getOrderGroup(orderId); + } catch (e: any) { + error = e?.message ?? "خطا در دریافت اطلاعات سفارش"; + } + + return ( + + + + ); +} \ No newline at end of file diff --git a/src/app/(customer)/(shop)/orders/layout.tsx b/src/app/(customer)/(shop)/orders/layout.tsx new file mode 100644 index 0000000..549d60a --- /dev/null +++ b/src/app/(customer)/(shop)/orders/layout.tsx @@ -0,0 +1,30 @@ +import OrderHeader from '@/components/orders/order-header'; +import { requireSession } from "@/lib/auth/session"; +import { getOrderGroups } from "@/lib/api/orders"; +import { OrdersProvider } from "@/lib/providers/orders-provider"; + +interface OrderLayoutInterface { + children: React.ReactNode; +} + +export default async function OrderLayout({ children }: OrderLayoutInterface) { + await requireSession("/login"); + + let orderGroups: Awaited> | null = null; + let error: string | null = null; + + try { + orderGroups = await getOrderGroups(); + } catch (e: any) { + error = e?.message ?? "خطا در دریافت سفارش‌ها"; + } + + return ( +
+ + + {children} + +
+ ); +} \ No newline at end of file diff --git a/src/app/(customer)/(shop)/orders/page.tsx b/src/app/(customer)/(shop)/orders/page.tsx deleted file mode 100644 index ecdd790..0000000 --- a/src/app/(customer)/(shop)/orders/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import Link from "next/link"; -import { requireSession } from "@/lib/auth/session"; -import { getOrderGroups } from "@/lib/api/orders"; -import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; - -export default async function OrdersPage() { - await requireSession("/orders"); - const orderGroups = await getOrderGroups(); - - return ( -
-

سفارش‌های من

- - {orderGroups.length === 0 &&

هنوز سفارشی ثبت نکرده‌اید.

} - -
- {orderGroups.map((group) => ( - -
- {new Date(group.created_at).toLocaleDateString("fa-IR")} - {PAYMENT_STATUS_LABELS_FA[group.payment_status]} -
-
- {group.orders.map((order) => ( - - {order.store.name} - - ))} -
-
- مجموع - {group.total_amount.toLocaleString("fa-IR")} تومان -
- - ))} -
-
- ); -} diff --git a/src/components/orders/order-detail-content.tsx b/src/components/orders/order-detail-content.tsx new file mode 100644 index 0000000..f8fdd13 --- /dev/null +++ b/src/components/orders/order-detail-content.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useOrderDetail } from "@/lib/providers/order-detail-provider"; +import OrderStepperStatus from "./order-stepper-status"; +import OrderTimeAddressInfoAlert from "./order-time-address-info-alert"; +import OrderItems from "./order-items"; +import OrderPaymentSticky from "./order-payment-sticky"; + +const STATUS_LABELS: Record = { + placed: "ثبت شد", + preparing: "در حال آماده‌سازی", + ready_to_ship: "آماده ارسال", + handed_to_courier: "ارسال شد", + delivered: "تحویل داده شد", + cancelled: "لغو شد", +}; + +export default function OrderDetailContent() { + const { data: orderGroup, error, isLoading } = useOrderDetail(); + + if (isLoading) { + return
در حال بارگذاری...
; + } + + if (error || !orderGroup) { + return ( +
+ {error ?? "سفارش یافت نشد"} +
+ ); + } + + // وضعیت اصلی رو از اولین سفارش می‌گیریم (یا می‌تونی منطق پیچیده‌تری بذاری) + const mainStatus = orderGroup.orders[0]?.status ?? "placed"; + + const statusToStep: Record = { + placed: 0, + preparing: 2, + ready_to_ship: 3, + handed_to_courier: 3, + delivered: 4, + cancelled: -1, + }; + + const currentStep = statusToStep[mainStatus] ?? 0; + const statusLabel = STATUS_LABELS[mainStatus] ?? mainStatus; + + return ( +
+

+ {statusLabel} +

+ + {mainStatus !== "cancelled" && ( + + )} + + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/orders/order-empty.tsx b/src/components/orders/order-empty.tsx new file mode 100644 index 0000000..3e434fc --- /dev/null +++ b/src/components/orders/order-empty.tsx @@ -0,0 +1,19 @@ +import { ShoppingBagIcon } from 'lucide-react' +import Link from 'next/link' + +const OrderEmpty = () => { + return ( +
+
+
+ +
+

هنوز سفارشی ثبت نکرده‌اید

+

لیست سفارش‌های شما در حال حاضر خالی است. برای خرید از فروشگاه‌های اطراف روی دکمه زیر کلیک کنید.

+ شروع خرید +
+
+ ) +} + +export default OrderEmpty \ No newline at end of file diff --git a/src/components/orders/order-header.tsx b/src/components/orders/order-header.tsx new file mode 100644 index 0000000..cd6ea49 --- /dev/null +++ b/src/components/orders/order-header.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { ArrowRight, ShoppingBagIcon } from 'lucide-react'; +import { usePathname, useRouter } from 'next/navigation'; + +const OrderHeader = () => { + const router = useRouter(); + const pathname = usePathname(); + const isExactOrders = pathname === '/orders'; + + const onBackHandler = () => { + router.back(); + }; + + const goToCartPage = () => { + router.push('/cart'); + }; + + return ( +
+
+ +

سفارش‌ها

+
+ + {isExactOrders && ( +
+
+ +
+ + + 90 + +
+ )} +
+ ); +}; + +export default OrderHeader; \ No newline at end of file diff --git a/src/components/orders/order-item.tsx b/src/components/orders/order-item.tsx new file mode 100644 index 0000000..3d60c47 --- /dev/null +++ b/src/components/orders/order-item.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import type { OrderGroup } from "@/types/api"; +import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; // اگه داری + +interface OrderItemProps { + orderGroup: OrderGroup; +} + +const OrderItem = ({ orderGroup }: OrderItemProps) => { + const router = useRouter(); + + const goToOrderDetail = () => { + router.push(`/orders/${orderGroup.uuid}`); // یا مسیر واقعی جزئیات + }; + + // وضعیت کلی (از اولین سفارش یا payment_status) + const mainOrder = orderGroup.orders[0]; + const status = mainOrder?.status ?? "placed"; + + // مپ وضعیت به فارسی و کلاس + const statusMap: Record = { + placed: { label: "ثبت شده", className: "bg-[#FFF4E5] text-[#E67E22]" }, + preparing: { label: "در حال آماده‌سازی", className: "bg-[#E5F3FF] text-[#007BFF]" }, + ready_to_ship: { label: "آماده ارسال", className: "bg-[#EBE5FF] text-[#734DBF]" }, + handed_to_courier: { label: "ارسال شد", className: "bg-teal-bg text-primary-action" }, + delivered: { label: "تحویل داده شد", className: "bg-emerald-bg text-success" }, + cancelled: { label: "لغو شد", className: "bg-[#FCE8E8] text-error" }, + }; + + const currentStatus = statusMap[status] ?? { label: status, className: "bg-gray-100 text-gray-700" }; + + // نام فروشگاه‌ها + const storeNames = orderGroup.orders.map(o => o.store.name).join(" + "); + + // تعداد کل کالاها + const totalItems = orderGroup.orders.reduce((sum, order) => { + return sum + order.items.reduce((s, item) => s + item.quantity, 0); + }, 0); + + // تصاویر محصولات (حداکثر ۵ تا) + const images = orderGroup.orders + .flatMap(order => order.items) + .slice(0, 5) + .map((item, index) => ({ + id: index, + src: "", // اگر تصویر محصول داری اینجا بذار، فعلاً خالی + alt: item.product_name_snapshot, + })); + + // تاریخ و ساعت + const createdDate = new Date(orderGroup.created_at); + const timeText = createdDate.toLocaleDateString("fa-IR", { + weekday: "long", + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+ {/* هدر */} +
+
+
+ {mainOrder?.store.logo_url ? ( + {mainOrder.store.name} + ) : ( + mainOrder?.store.name?.charAt(0) ?? "س" + )} +
+
+

{storeNames}

+

+ {orderGroup.orders.length > 1 + ? `${orderGroup.orders.length} فروشگاه` + : mainOrder?.items[0]?.product_name_snapshot ?? "سفارش"} +

+
+
+ + + {currentStatus.label} + +
+ +
+ + {/* پایین کارت */} +
+
+ {images.length > 0 ? ( + images.map((img) => ( +
+ {img.src ? ( + {img.alt} + ) : ( +
+ {img.alt.charAt(0)} +
+ )} +
+ )) + ) : ( +
بدون تصویر
+ )} +
+ +
+
{timeText}
+
+ {totalItems} کالا • {orderGroup.total_amount.toLocaleString("fa-IR")} تومان +
+
+
+ + +
+ ); +}; + +export default OrderItem; \ No newline at end of file diff --git a/src/components/orders/order-items.tsx b/src/components/orders/order-items.tsx new file mode 100644 index 0000000..5e86468 --- /dev/null +++ b/src/components/orders/order-items.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { OrderGroup } from "@/types/api"; +import {formatTomanPrice} from "@/lib/utils/priceUtils"; + +interface Props { + order: OrderGroup; +} + +const OrderItems = ({ order }: Props) => { + // همه آیتم‌های همه فروشگاه‌ها رو جمع می‌کنیم + const allItems = order.orders.flatMap((o) => + o.items.map((item) => ({ + ...item, + storeName: o.store.name, + })) + ); + + return ( +
+

اقلام سفارش

+ + {allItems.map((item) => ( +
+
+
+ {item.product_name_snapshot} + + {item.quantity.toLocaleString("fa-IR")} عدد + +
+ {order.orders.length > 1 && ( + {item.storeName} + )} +
+ + {formatTomanPrice(item.line_total)} + +
+ ))} +
+ ); +}; + +export default OrderItems; \ No newline at end of file diff --git a/src/components/orders/order-payment-sticky.tsx b/src/components/orders/order-payment-sticky.tsx new file mode 100644 index 0000000..afc47bb --- /dev/null +++ b/src/components/orders/order-payment-sticky.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { OrderGroup } from "@/types/api"; +import {formatTomanPrice} from "@/lib/utils/priceUtils"; + +interface Props { + order: OrderGroup; +} + +const OrderPaymentSticky = ({ order }: Props) => { + const paymentMethodLabel: Record = { + wallet: "پرداخت از کیف پول", + online: "پرداخت آنلاین", + cash_on_delivery: "پرداخت در محل", + }; + + return ( +
+
+
+
+ مبلغ پرداختی + + {paymentMethodLabel[order.payment_method] ?? order.payment_method} + +
+ + {formatTomanPrice(order.total_amount)} + +
+ + {/* فعلاً فقط نمایش، بعداً می‌تونی دکمه لغو اضافه کنی */} + +
+ +
+
+
+
+ ); +}; + +export default OrderPaymentSticky; \ No newline at end of file diff --git a/src/components/orders/order-server-error.tsx b/src/components/orders/order-server-error.tsx new file mode 100644 index 0000000..d9764ba --- /dev/null +++ b/src/components/orders/order-server-error.tsx @@ -0,0 +1,24 @@ +import { ServerCrashIcon } from 'lucide-react' +import React from 'react' + +const OrderServerError = () => { + return ( +
+
+
+ {/* */} + وینسو +
+

خطا در برقراری ارتباط

+

+ متأسفانه در دریافت اطلاعات سفارش‌ها مشکلی پیش آمده است. لطفاً اتصال اینترنت خود را بررسی کرده و مجدداً تلاش کنید. +

+ +
+
+ ) +} + +export default OrderServerError \ No newline at end of file diff --git a/src/components/orders/order-stepper-status.tsx b/src/components/orders/order-stepper-status.tsx new file mode 100644 index 0000000..e21217e --- /dev/null +++ b/src/components/orders/order-stepper-status.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Check } from 'lucide-react' + +type StepStatus = 'completed' | 'current' | 'upcoming' + +interface Step { + label: string + status: StepStatus +} + +interface OrderStepperStatusProps { + /** ایندکس مرحله فعلی (۰-based). مراحل قبل completed، خودش current، بعد upcoming */ + currentStep?: number + steps?: string[] +} + +const DEFAULT_STEPS = [ + 'ثبت شد', + 'پذیرفته شد', + 'آماده‌سازی', + 'ارسال شد', + 'تحویل', +] + +const OrderStepperStatus = ({ + currentStep = 2, + steps = DEFAULT_STEPS, +}: OrderStepperStatusProps) => { + const computedSteps: Step[] = steps.map((label, index) => { + if (index < currentStep) return { label, status: 'completed' } + if (index === currentStep) return { label, status: 'current' } + return { label, status: 'upcoming' } + }) + + return ( +
+
+ {computedSteps.map((step, index) => { + const isLast = index === computedSteps.length - 1 + const isCompleted = step.status === 'completed' + const isCurrent = step.status === 'current' + + const circleBg = step.status === 'upcoming' ? 'bg-gray-300' : 'bg-primary-action' + const nextLineBg = + index < currentStep ? 'bg-primary-action' : 'bg-gray-300' + + return ( + +
+ {isCompleted && ( + + )} + {isCurrent && ( +
+ )} +
+ + {!isLast && ( +
+ )} + + ) + })} +
+ +
+ {computedSteps.map((step, index) => { + const isLast = index === computedSteps.length - 1 + const textColor = + step.status === 'upcoming' + ? 'text-gray-500' + : 'text-primary-action' + + return ( + +
+ + {step.label} + +
+ {!isLast &&
} + + ) + })} +
+
+ ) +} + +export default OrderStepperStatus \ No newline at end of file diff --git a/src/components/orders/order-time-address-info-alert.tsx b/src/components/orders/order-time-address-info-alert.tsx new file mode 100644 index 0000000..b490233 --- /dev/null +++ b/src/components/orders/order-time-address-info-alert.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ClockIcon, MapPinIcon } from "lucide-react"; +import type { OrderGroup } from "@/types/api"; + +interface Props { + order: OrderGroup; +} + +const OrderTimeAddressInfoAlert = ({ order }: Props) => { + // زمان تخمینی (فعلاً ساده) + const estimatedTime = + order.delivery_type === "express" + ? "۳۰–۴۵ دقیقه دیگر" + : order.scheduled_at + ? new Date(order.scheduled_at).toLocaleString("fa-IR") + : "زمان مشخص نشده"; + + return ( +
+
+ +

+ زمان تخمینی تحویل: {estimatedTime} +

+
+
+ +

{order.full_address}

+
+
+ ); +}; + +export default OrderTimeAddressInfoAlert; \ No newline at end of file diff --git a/src/components/orders/orders-skeleton.tsx b/src/components/orders/orders-skeleton.tsx new file mode 100644 index 0000000..233ec2e --- /dev/null +++ b/src/components/orders/orders-skeleton.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +const OrdersSkeleton = () => { + return ( +
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ {Array.from({ length: 3 }).map((_, imgIndex) => ( +
+ ))} +
+ +
+
+
+
+
+ +
+
+ ))} +
+ ); +}; + +export default OrdersSkeleton; \ No newline at end of file diff --git a/src/lib/providers/order-detail-provider.tsx b/src/lib/providers/order-detail-provider.tsx new file mode 100644 index 0000000..9d95b9a --- /dev/null +++ b/src/lib/providers/order-detail-provider.tsx @@ -0,0 +1,42 @@ +"use client"; + +import {createContext, useContext, type ReactNode} from "react"; +import type {Order, OrderGroup} from "@/types/api"; + +type OrderDetailContextValue = { + data: OrderGroup | null; + error: string | null; + isLoading: boolean; +}; + +const OrderDetailContext = createContext(undefined); + +export function OrderDetailProvider({ + children, + order, + error = null, + }: { + children: ReactNode; + order: OrderGroup | null; + error?: string | null; +}) { + return ( + + {children} + + ); +} + +export function useOrderDetail() { + const ctx = useContext(OrderDetailContext); + if (ctx === undefined) { + throw new Error("useOrderDetail must be used within OrderDetailProvider"); + } + return ctx; +} \ No newline at end of file diff --git a/src/lib/providers/orders-provider.tsx b/src/lib/providers/orders-provider.tsx new file mode 100644 index 0000000..12b3900 --- /dev/null +++ b/src/lib/providers/orders-provider.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import type { OrderGroup } from "@/types/api"; + +type OrdersContextValue = { + data: OrderGroup[] | null; + error: string | null; + isLoading: boolean; +}; + +const OrdersContext = createContext(undefined); + +export function OrdersProvider({ + children, + orderGroups, + error = null, + }: { + children: ReactNode; + orderGroups: OrderGroup[] | null; + error?: string | null; +}) { + return ( + + {children} + + ); +} + +export function useOrders() { + const ctx = useContext(OrdersContext); + if (ctx === undefined) { + throw new Error("useOrders must be used within OrdersProvider"); + } + return ctx; +} \ No newline at end of file