diff --git a/src/app/(customer)/(shop)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx index 243db0c..56ee906 100644 --- a/src/app/(customer)/(shop)/cart/page.tsx +++ b/src/app/(customer)/(shop)/cart/page.tsx @@ -1,14 +1,13 @@ import Link from "next/link"; +import { ArrowRight } from "lucide-react"; import { getCart } from "@/lib/api/cart"; import { requireSession } from "@/lib/auth/session"; -import { CartItemRow } from "@/components/customer/cart-item-row"; -import { ClearCartButton } from "@/components/customer/clear-cart-button"; +import { StoreCartCard } from "@/components/customer/store-cart-card"; import { Button } from "@/components/ui/button"; export default async function CartPage() { await requireSession("/cart"); const cart = await getCart(); - const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order); if (cart.groups.length === 0) { return ( @@ -21,58 +20,64 @@ export default async function CartPage() { ); } + const totalItemLines = cart.groups.reduce( + (sum, g) => sum + g.items.length, + 0, + ); + return ( -
-
-

سبد خرید

- +
+ {/* DOM order intentionally mirrors the screenshot's left→right layout, not reading + order — under this app's inherited dir="rtl", the first child renders at the + right edge and the last at the left. */} +
+ + + +

+ سبد خرید +

- {cart.groups.map((group) => ( -
- - {group.store.name} - +
+ {cart.groups.map((group) => ( + + {!group.meets_minimum_order && ( +

+ حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است. +

+ )} -
- {group.items.map((item) => ( - - ))} -
+ + + +
+ ))} +
-
-
- جمع کالاها - {group.items_subtotal.toLocaleString("fa-IR")} تومان -
-
- هزینه ارسال - {group.delivery_fee.toLocaleString("fa-IR")} تومان -
-
- مجموع - {group.total.toLocaleString("fa-IR")} تومان -
-
- - {!group.meets_minimum_order && ( -

- حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است. -

- )} -
- ))} - -
-
- مبلغ قابل پرداخت - {cart.grand_total.toLocaleString("fa-IR")} تومان +
+
+ + جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "} + {totalItemLines.toLocaleString("fa-IR")} کالا) + +

+ هر فروشگاه جداگانه پرداخت و ارسال می‌شود +

- - - + + {cart.grand_total.toLocaleString("fa-IR")} تومان +
); diff --git a/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx new file mode 100644 index 0000000..9411ca7 --- /dev/null +++ b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowRight } from "lucide-react"; +import { getCart } from "@/lib/api/cart"; +import { getAddresses } from "@/lib/api/addresses"; +import { requireSession } from "@/lib/auth/session"; +import { CheckoutPayment } from "@/components/customer/checkout-payment"; + +export default async function ConfirmPaymentPage({ params }: PageProps<"/checkout/[store]/confirm">) { + const { store } = await params; + await requireSession(`/checkout/${store}/confirm`); + + const [cart, addresses] = await Promise.all([ + getCart(), + // Known gap (FRONTEND_GUIDE.md §7): addresses GET can 403 on the current OAuth + // scope — fall back to empty rather than crashing this page over it. + getAddresses().catch(() => []), + ]); + const group = cart.groups.find((g) => g.store.uuid === store); + if (!group) notFound(); + + return ( +
+ {/* Same header pattern as /cart and /checkout/[store]. */} +
+ + + +

تأیید و پرداخت

+
+ + +
+ ); +} diff --git a/src/app/(customer)/(shop)/checkout/[store]/page.tsx b/src/app/(customer)/(shop)/checkout/[store]/page.tsx new file mode 100644 index 0000000..b295587 --- /dev/null +++ b/src/app/(customer)/(shop)/checkout/[store]/page.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowRight } from "lucide-react"; +import { getCart } from "@/lib/api/cart"; +import { requireSession } from "@/lib/auth/session"; +import { StoreCartCard } from "@/components/customer/store-cart-card"; +import { CheckoutFulfillment } from "@/components/customer/checkout-fulfillment"; + +export default async function StoreCheckoutPage({ params }: PageProps<"/checkout/[store]">) { + const { store } = await params; + await requireSession(`/checkout/${store}`); + const cart = await getCart(); + const group = cart.groups.find((g) => g.store.uuid === store); + if (!group) notFound(); + + return ( +
+ {/* Same header as /cart, same DOM-order note: first child renders right, last left. */} +
+ + + +

سبد خرید

+
+ + + + +
+ ); +} diff --git a/src/app/(customer)/(shop)/products/[uuid]/page.tsx b/src/app/(customer)/(shop)/products/[uuid]/page.tsx index e2fbc5e..286a980 100644 --- a/src/app/(customer)/(shop)/products/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/products/[uuid]/page.tsx @@ -14,8 +14,8 @@ export default async function ProductDetailPage({ params }: PageProps<"/products if (!product) notFound(); return ( -
-
+
+
{product.image_url && ( // eslint-disable-next-line @next/next/no-img-element {product.name} @@ -24,9 +24,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
-
+

{product.name}

- + {product.store.name}

diff --git a/src/components/customer/cart-item-row.tsx b/src/components/customer/cart-item-row.tsx index 0a92ad9..df582a2 100644 --- a/src/components/customer/cart-item-row.tsx +++ b/src/components/customer/cart-item-row.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useTransition } from "react"; import { useRouter, usePathname } from "next/navigation"; +import { Minus, Plus, Trash2 } from "lucide-react"; import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart"; import type { CartItem } from "@/types/api"; @@ -33,41 +34,58 @@ export function CartItemRow({ item }: { item: CartItem }) { } return ( -

-
-
+
+ {/* DOM order intentionally mirrors the screenshot's left→right layout, not reading + order — under this app's inherited dir="rtl", the first child renders at the + right edge and the last at the left. */} +
+
{item.product.image_url && ( // eslint-disable-next-line @next/next/no-img-element )}
-
- {item.product.name} - {item.product.price.toLocaleString("fa-IR")} تومان +
+ {item.product.name} + + {item.product.price.toLocaleString("fa-IR")} تومان +
-
+
+ +
+
- {item.quantity.toLocaleString("fa-IR")} + + {item.quantity.toLocaleString("fa-IR")} +
- {item.line_total.toLocaleString("fa-IR")} +
+ {error &&

{error}

}
); diff --git a/src/components/customer/checkout-fulfillment.tsx b/src/components/customer/checkout-fulfillment.tsx new file mode 100644 index 0000000..a3e638a --- /dev/null +++ b/src/components/customer/checkout-fulfillment.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import type { CartGroup } from "@/types/api"; + +/** + * "دریافت حضوری" vs "ارسال توسط فروشگاه" has no backing field in the Checkout API — + * the OpenAPI spec's Checkout schema is address_uuid/delivery_type/scheduled_at/ + * payment_method/notes only, with delivery_type meaning express-vs-scheduled timing, + * not pickup-vs-delivery fulfillment. So this toggle only affects what's *displayed* + * here; the actual order submission (next screen) still uses the real delivery fee. + */ +export function CheckoutFulfillment({ group }: { group: CartGroup }) { + const [pickup, setPickup] = useState(false); + const deliveryFee = pickup ? 0 : group.delivery_fee; + const payable = group.items_subtotal + deliveryFee; + + return ( +
+
+

نحوه دریافت

+ + + + +
+ +
+
+ جمع کالاها + {group.items_subtotal.toLocaleString("fa-IR")} تومان +
+
+ هزینه ارسال + {deliveryFee.toLocaleString("fa-IR")} تومان +
+
+ مبلغ قابل پرداخت + + {payable.toLocaleString("fa-IR")} تومان + +
+
+ +
+ {/* Pickup has its own confirm+pay screen (no address needed); delivery still + goes to the general /checkout form until that flow's design is provided. */} + + + +
+
+ ); +} diff --git a/src/components/customer/checkout-payment.tsx b/src/components/customer/checkout-payment.tsx new file mode 100644 index 0000000..d61de45 --- /dev/null +++ b/src/components/customer/checkout-payment.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { submitCheckout } from "@/lib/api/checkout"; +import type { Address, CartGroup, PaymentMethod } from "@/types/api"; + +/** Exact labels per the OpenAPI PaymentMethodEnum description ("(آنلاین)" added to match the screenshot). */ +const PAYMENT_OPTIONS: { value: PaymentMethod; label: string }[] = [ + { value: "wallet", label: "کیف پول وینسو" }, + { value: "online", label: "درگاه بانکی (آنلاین)" }, + { value: "cash_on_delivery", label: "پرداخت در محل" }, +]; + +export function CheckoutPayment({ group, addresses }: { group: CartGroup; addresses: Address[] }) { + const router = useRouter(); + const [method, setMethod] = useState("wallet"); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + // Pickup flow — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total. + const payable = group.items_subtotal; + + function handlePay() { + setError(null); + const addressUuid = addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid; + if (!addressUuid) { + setError("برای ثبت سفارش ابتدا یک آدرس اضافه کنید."); + return; + } + startTransition(async () => { + // submitCheckout processes the whole cart at once (splits into per-store orders + // server-side), not just this store — see PageProps note on the confirm page. + const result = await submitCheckout({ + address_uuid: addressUuid, + delivery_type: "express", + scheduled_at: null, + payment_method: method, + notes: "", + }); + if (result.ok) { + router.push(`/order-groups/${result.data.uuid}`); + } else { + setError(result.message); + } + }); + } + + return ( +
+
+

روش پرداخت

+ + {PAYMENT_OPTIONS.map((option) => { + const selected = method === option.value; + return ( + + ); + })} +
+ + {error &&

{error}

} + +
+
+ + {group.items.length.toLocaleString("fa-IR")} کالا از {group.store.name} + + {payable.toLocaleString("fa-IR")} تومان +
+
+ جمع کل + + {payable.toLocaleString("fa-IR")} تومان + +
+ +
+
+ ); +} diff --git a/src/components/customer/store-cart-card.tsx b/src/components/customer/store-cart-card.tsx new file mode 100644 index 0000000..9500a32 --- /dev/null +++ b/src/components/customer/store-cart-card.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { ShoppingBasket } from "lucide-react"; +import { CartItemRow } from "@/components/customer/cart-item-row"; +import type { CartGroup } from "@/types/api"; +import type { ReactNode } from "react"; + +/** Shared between /cart and /checkout/[store] — the pixel-perfect per-store card. */ +export function StoreCartCard({ group, children }: { group: CartGroup; children?: ReactNode }) { + return ( +
+ + {group.items.length.toLocaleString("fa-IR")} کالا + + + + + {group.store.name} + + +
+ {group.items.map((item) => ( + + ))} +
+ +
+ + جمع فروشگاه: {group.items_subtotal.toLocaleString("fa-IR")} تومان + +
+ + {children} +
+ ); +} diff --git a/src/components/product/product-purchase-panel.tsx b/src/components/product/product-purchase-panel.tsx index 5a8d00a..d7f431e 100644 --- a/src/components/product/product-purchase-panel.tsx +++ b/src/components/product/product-purchase-panel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useTransition, type ReactNode } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { Minus, Plus, X, CheckCircle2 } from "lucide-react"; +import { Minus, Plus, X, CheckCircle2, Check } from "lucide-react"; import { addToCart } from "@/lib/api/cart"; import { toPersianDigits } from "@/lib/format/persian-digits"; import type { ProductDetail } from "@/types/api"; @@ -41,17 +41,19 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe return ( <> + {/* DOM order intentionally mirrors the screenshot's left→right layout, not reading + order — under this app's inherited dir="rtl", the first child renders at the + right edge and the last at the left, so "what should appear on the left" must + come last in JSX (stepper group, then its own "+" trailing last so it lands + leftmost within the group). */}
+ + {product.is_out_of_stock ? "ناموجود" : "موجود"} + {!product.is_out_of_stock && } +
- - {toPersianDigits(quantity)} + {toPersianDigits(quantity)} +
- - {product.is_out_of_stock ? "ناموجود" : "موجود ●"} -
@@ -73,35 +81,35 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe {error &&

{error}

} {showToast && ( -
- +
+ محصول به سبد خرید اضافه شد - +
)} -
+
- -
+
جمع کل {(product.price * quantity).toLocaleString("fa-IR")} تومان
+
-
+
); diff --git a/src/components/store/sticky-cart-bar.tsx b/src/components/store/sticky-cart-bar.tsx index c0744a8..523f79d 100644 --- a/src/components/store/sticky-cart-bar.tsx +++ b/src/components/store/sticky-cart-bar.tsx @@ -1,32 +1,31 @@ import Link from "next/link"; -import { ShoppingBag } from "lucide-react"; /** designspec.md §2.6 — only rendered when this store's cart group actually has items. */ export function StickyCartBar({ itemCount, total }: { itemCount: number; total: number }) { return ( -
+
+ {/* DOM order intentionally mirrors the screenshot's left→right layout, not reading + order — under this app's inherited dir="rtl", the first child renders at the + right edge and the last at the left. */}
- - - - - {itemCount.toLocaleString("fa-IR")} - - - مشاهده سبد - - -
+
جمع کل {total.toLocaleString("fa-IR")} تومان
+ + + مشاهده سبد + + {itemCount.toLocaleString("fa-IR")} + +
-
+
); } diff --git a/src/components/ui/hero-back-button.tsx b/src/components/ui/hero-back-button.tsx index f2e5dbd..98ab8d7 100644 --- a/src/components/ui/hero-back-button.tsx +++ b/src/components/ui/hero-back-button.tsx @@ -7,9 +7,9 @@ export function HeroBackButton({ href }: { href: string }) { - + ); }