From d000df2e412d743f148d3d82cb6d51e3931f0421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Mon, 17 Aug 2026 10:19:59 +0330 Subject: [PATCH] feat: customer shopping flow (store/product pages, cart, addresses, checkout, order tracking); auth calls Gooyal directly from browser --- src/app/(auth)/login/page.tsx | 26 +-- src/app/(customer)/addresses/page.tsx | 32 +++ src/app/(customer)/cart/page.tsx | 73 +++++++ src/app/(customer)/checkout/page.tsx | 23 ++ src/app/(customer)/error.tsx | 19 ++ src/app/(customer)/layout.tsx | 42 ++++ .../(customer)/order-groups/[uuid]/page.tsx | 53 +++++ src/app/(customer)/orders/page.tsx | 47 +++++ src/app/(customer)/page.tsx | 15 +- src/app/(customer)/products/[uuid]/page.tsx | 54 +++++ src/app/(customer)/stores/[uuid]/page.tsx | 136 ++++++++++++ src/app/api/auth/request-otp/route.ts | 21 -- src/app/api/auth/session/route.ts | 26 +++ src/app/api/auth/verify-otp/route.ts | 29 --- .../customer/add-to-cart-button.tsx | 54 +++++ src/components/customer/address-list.tsx | 198 ++++++++++++++++++ src/components/customer/cart-item-row.tsx | 60 ++++++ src/components/customer/checkout-form.tsx | 158 ++++++++++++++ src/components/customer/logout-button.tsx | 23 ++ src/components/customer/order-card.tsx | 76 +++++++ src/lib/actions/addresses.ts | 44 ++++ src/lib/actions/cart.ts | 44 ++++ src/lib/actions/checkout.ts | 17 ++ src/lib/actions/orders.ts | 17 ++ src/lib/actions/result.ts | 18 ++ src/lib/api/media-client.ts | 8 + src/lib/auth/gooyal-client.ts | 93 ++++++++ src/lib/auth/gooyal.ts | 45 ---- src/lib/order-status.ts | 24 +++ src/proxy.ts | 11 +- 30 files changed, 1368 insertions(+), 118 deletions(-) create mode 100644 src/app/(customer)/addresses/page.tsx create mode 100644 src/app/(customer)/cart/page.tsx create mode 100644 src/app/(customer)/checkout/page.tsx create mode 100644 src/app/(customer)/error.tsx create mode 100644 src/app/(customer)/layout.tsx create mode 100644 src/app/(customer)/order-groups/[uuid]/page.tsx create mode 100644 src/app/(customer)/orders/page.tsx create mode 100644 src/app/(customer)/products/[uuid]/page.tsx create mode 100644 src/app/(customer)/stores/[uuid]/page.tsx delete mode 100644 src/app/api/auth/request-otp/route.ts create mode 100644 src/app/api/auth/session/route.ts delete mode 100644 src/app/api/auth/verify-otp/route.ts create mode 100644 src/components/customer/add-to-cart-button.tsx create mode 100644 src/components/customer/address-list.tsx create mode 100644 src/components/customer/cart-item-row.tsx create mode 100644 src/components/customer/checkout-form.tsx create mode 100644 src/components/customer/logout-button.tsx create mode 100644 src/components/customer/order-card.tsx create mode 100644 src/lib/actions/addresses.ts create mode 100644 src/lib/actions/cart.ts create mode 100644 src/lib/actions/checkout.ts create mode 100644 src/lib/actions/orders.ts create mode 100644 src/lib/actions/result.ts create mode 100644 src/lib/api/media-client.ts create mode 100644 src/lib/auth/gooyal-client.ts create mode 100644 src/lib/order-status.ts diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 3842cc5..50a8e7d 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { requestOtp, exchangeOtpForToken, normalizeIranianPhone, GooyalClientError } from "@/lib/auth/gooyal-client"; type Step = "phone" | "otp"; @@ -23,16 +24,11 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const res = await fetch("/api/auth/request-otp", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phone_number: phone }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.message ?? "خطا در ارسال کد تایید"); + // Calls Gooyal accounts directly from the browser — not our own server. + await requestOtp(normalizeIranianPhone(phone)); setStep("otp"); } catch (err) { - setError(err instanceof Error ? err.message : "خطا در ارسال کد تایید"); + setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "خطا در ارسال کد تایید"); } finally { setLoading(false); } @@ -43,17 +39,21 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const res = await fetch("/api/auth/verify-otp", { + // Token exchange also happens directly against Gooyal from the browser. + const token = await exchangeOtpForToken(normalizeIranianPhone(phone), otp); + + // Hand the token to our server just to wrap it in an httpOnly session cookie. + const sessionRes = await fetch("/api/auth/session", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phone_number: phone, otp }), + body: JSON.stringify(token), }); - const data = await res.json(); - if (!res.ok) throw new Error(data.message ?? "کد تایید نامعتبر است"); + if (!sessionRes.ok) throw new Error("خطا در برقراری نشست ورود"); + router.push(next); router.refresh(); } catch (err) { - setError(err instanceof Error ? err.message : "کد تایید نامعتبر است"); + setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "کد تایید نامعتبر است"); } finally { setLoading(false); } diff --git a/src/app/(customer)/addresses/page.tsx b/src/app/(customer)/addresses/page.tsx new file mode 100644 index 0000000..9698b76 --- /dev/null +++ b/src/app/(customer)/addresses/page.tsx @@ -0,0 +1,32 @@ +import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; +import type { Address, City, Neighborhood } from "@/types/api"; +import { AddressList } from "@/components/customer/address-list"; + +async function getAddresses() { + const res = await winofyFetch | Address[]>("/v1/addresses/"); + return Array.isArray(res) ? res : res.results; +} + +async function getCities() { + const res = await winofyFetch | City[]>("/v1/cities/", { auth: false }); + return Array.isArray(res) ? res : res.results; +} + +async function getNeighborhoods() { + const res = await winofyFetch | Neighborhood[]>("/v1/neighborhoods/", { + auth: false, + params: { limit: 200 }, + }); + return Array.isArray(res) ? res : res.results; +} + +export default async function AddressesPage() { + const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]); + + return ( +
+

آدرس‌های من

+ +
+ ); +} diff --git a/src/app/(customer)/cart/page.tsx b/src/app/(customer)/cart/page.tsx new file mode 100644 index 0000000..4bacd17 --- /dev/null +++ b/src/app/(customer)/cart/page.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; +import { getCart } from "@/lib/actions/cart"; +import { CartItemRow } from "@/components/customer/cart-item-row"; +import { Button } from "@/components/ui/button"; + +export default async function CartPage() { + const cart = await getCart(); + const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order); + + if (cart.groups.length === 0) { + return ( +
+

سبد خرید شما خالی است.

+ + + +
+ ); + } + + return ( +
+

سبد خرید

+ + {cart.groups.map((group) => ( +
+ + {group.store.name} + + +
+ {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")} تومان +
+ + + +
+
+ ); +} diff --git a/src/app/(customer)/checkout/page.tsx b/src/app/(customer)/checkout/page.tsx new file mode 100644 index 0000000..57dfcff --- /dev/null +++ b/src/app/(customer)/checkout/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; +import { getCart } from "@/lib/actions/cart"; +import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; +import type { Address } from "@/types/api"; +import { CheckoutForm } from "@/components/customer/checkout-form"; + +async function getAddresses() { + const res = await winofyFetch | Address[]>("/v1/addresses/"); + return Array.isArray(res) ? res : res.results; +} + +export default async function CheckoutPage() { + const [cart, addresses] = await Promise.all([getCart(), getAddresses()]); + + if (cart.groups.length === 0) redirect("/cart"); + + return ( +
+

تکمیل سفارش

+ +
+ ); +} diff --git a/src/app/(customer)/error.tsx b/src/app/(customer)/error.tsx new file mode 100644 index 0000000..3020c1b --- /dev/null +++ b/src/app/(customer)/error.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; + +export default function CustomerError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+

{error.message || "خطایی رخ داد. لطفاً دوباره تلاش کنید."}

+ +
+ ); +} diff --git a/src/app/(customer)/layout.tsx b/src/app/(customer)/layout.tsx new file mode 100644 index 0000000..c392dc7 --- /dev/null +++ b/src/app/(customer)/layout.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { getSession } from "@/lib/auth/session"; +import { LogoutButton } from "@/components/customer/logout-button"; + +export default async function CustomerLayout({ children }: LayoutProps<"/">) { + const session = await getSession(); + + return ( +
+
+
+ + وینوفی + + +
+
+ {children} +
+ ); +} diff --git a/src/app/(customer)/order-groups/[uuid]/page.tsx b/src/app/(customer)/order-groups/[uuid]/page.tsx new file mode 100644 index 0000000..c435c80 --- /dev/null +++ b/src/app/(customer)/order-groups/[uuid]/page.tsx @@ -0,0 +1,53 @@ +import { notFound } from "next/navigation"; +import { winofyFetch, ApiError } from "@/lib/api/winofy"; +import { OrderCard } from "@/components/customer/order-card"; +import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; +import type { OrderGroup } from "@/types/api"; + +async function getOrderGroup(uuid: string) { + try { + return await winofyFetch(`/v1/order-groups/${uuid}/`); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } +} + +export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) { + const { uuid } = await params; + const orderGroup = await getOrderGroup(uuid); + if (!orderGroup) notFound(); + + return ( +
+
+ ✓ +

سفارش شما ثبت شد

+

+ وضعیت پرداخت: {PAYMENT_STATUS_LABELS_FA[orderGroup.payment_status]} +

+

{orderGroup.full_address}

+
+ + {orderGroup.payment?.payment_url && ( + + ادامه پرداخت + + )} + +
+ {orderGroup.orders.map((order) => ( + + ))} +
+ +
+ مجموع کل + {orderGroup.total_amount.toLocaleString("fa-IR")} تومان +
+
+ ); +} diff --git a/src/app/(customer)/orders/page.tsx b/src/app/(customer)/orders/page.tsx new file mode 100644 index 0000000..76af803 --- /dev/null +++ b/src/app/(customer)/orders/page.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; +import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; +import type { OrderGroup } from "@/types/api"; + +async function getOrderGroups() { + const res = await winofyFetch>("/v1/order-groups/"); + return res.results; +} + +export default async function OrdersPage() { + 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/app/(customer)/page.tsx b/src/app/(customer)/page.tsx index c2acb04..4c63984 100644 --- a/src/app/(customer)/page.tsx +++ b/src/app/(customer)/page.tsx @@ -20,13 +20,6 @@ export default async function HomePage() { return (
-
-

وینوفی

- - ورود - -
-

دسته‌بندی‌ها

@@ -50,7 +43,11 @@ export default async function HomePage() {

فروشگاه‌های نزدیک شما

{stores.map((store) => ( -
+
{store.logo ? ( // eslint-disable-next-line @next/next/no-img-element @@ -75,7 +72,7 @@ export default async function HomePage() { حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان
-
+ ))} {stores.length === 0 &&

فروشگاهی یافت نشد.

}
diff --git a/src/app/(customer)/products/[uuid]/page.tsx b/src/app/(customer)/products/[uuid]/page.tsx new file mode 100644 index 0000000..a9f58d8 --- /dev/null +++ b/src/app/(customer)/products/[uuid]/page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { winofyFetch, ApiError } from "@/lib/api/winofy"; +import { resolveMediaUrl } from "@/lib/api/media"; +import type { ProductDetail } from "@/types/api"; +import { AddToCartButton } from "@/components/customer/add-to-cart-button"; + +const UNIT_LABELS_FA: Record = { gram: "گرم", ml: "میلی‌لیتر", piece: "عدد" }; + +async function getProduct(uuid: string) { + try { + return await winofyFetch(`/v1/products/${uuid}/`, { auth: false }); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } +} + +export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) { + const { uuid } = await params; + const product = await getProduct(uuid); + if (!product) notFound(); + + return ( +
+ + ← بازگشت به {product.store.name} + + +
+ {product.image && ( + // eslint-disable-next-line @next/next/no-img-element + {product.name} + )} +
+ +
+

{product.name}

+

+ {product.unit_value} {UNIT_LABELS_FA[product.unit_type] ?? product.unit_type} +

+

{product.price.toLocaleString("fa-IR")} تومان

+ {product.description &&

{product.description}

} +
+ + +
+ ); +} diff --git a/src/app/(customer)/stores/[uuid]/page.tsx b/src/app/(customer)/stores/[uuid]/page.tsx new file mode 100644 index 0000000..f3d9930 --- /dev/null +++ b/src/app/(customer)/stores/[uuid]/page.tsx @@ -0,0 +1,136 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy"; +import { resolveMediaUrl } from "@/lib/api/media"; +import type { ProductListItem, StoreDetail } from "@/types/api"; +import { AddToCartButton } from "@/components/customer/add-to-cart-button"; + +const WEEKDAY_LABELS_FA = ["شنبه", "یک‌شنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه"]; + +async function getStore(uuid: string) { + try { + return await winofyFetch(`/v1/stores/${uuid}/`, { auth: false }); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } +} + +async function getStoreProducts(uuid: string) { + const res = await winofyFetch>("/v1/products/", { + auth: false, + params: { store: uuid, limit: 100 }, + }); + return res.results; +} + +export default async function StoreDetailPage({ params }: PageProps<"/stores/[uuid]">) { + const { uuid } = await params; + const store = await getStore(uuid); + if (!store) notFound(); + + const products = await getStoreProducts(uuid); + + return ( +
+
+
+ {store.cover_image && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +
+
+
+ {store.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + {store.name} + ) : ( +
+ )} +
+

{store.name}

+

{store.category.name}

+

+ ⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count} نظر) ·{" "} + {store.is_open ? "باز" : "بسته"} +

+
+
+ + {store.description &&

{store.description}

} + +
+ + حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان + + + هزینه ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان + + {store.free_delivery_threshold != null && ( + + ارسال رایگان بالای {store.free_delivery_threshold.toLocaleString("fa-IR")} تومان + + )} +
+ +
+ {store.accepts_wallet && کیف پول} + {store.accepts_online && درگاه بانکی} + {store.accepts_cash_on_delivery && ( + پرداخت در محل + )} +
+ + {store.working_hours?.length > 0 && ( +
+ ساعات کاری +
    + {store.working_hours.map((wh) => ( +
  • + {WEEKDAY_LABELS_FA[wh.weekday]} + {wh.is_closed ? "تعطیل" : `${wh.opens_at} تا ${wh.closes_at}`} +
  • + ))} +
+
+ )} +
+
+ +
+

محصولات

+
+ {products.map((product) => ( +
+ +
+ {product.image && ( + // eslint-disable-next-line @next/next/no-img-element + {product.name} + )} +
+

{product.name}

+

{product.price.toLocaleString("fa-IR")} تومان

+ + +
+ ))} + {products.length === 0 &&

محصولی یافت نشد.

} +
+
+
+ ); +} diff --git a/src/app/api/auth/request-otp/route.ts b/src/app/api/auth/request-otp/route.ts deleted file mode 100644 index 001d8e7..0000000 --- a/src/app/api/auth/request-otp/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextResponse } from "next/server"; -import { requestOtp, GooyalAuthError } from "@/lib/auth/gooyal"; - -export async function POST(request: Request) { - const body = await request.json().catch(() => null); - const phoneNumber = body?.phone_number; - - if (!phoneNumber || typeof phoneNumber !== "string") { - return NextResponse.json({ message: "شماره موبایل الزامی است." }, { status: 400 }); - } - - try { - const result = await requestOtp(phoneNumber); - return NextResponse.json(result, { status: 201 }); - } catch (err) { - if (err instanceof GooyalAuthError) { - return NextResponse.json({ message: err.message, code: err.code }, { status: err.status }); - } - return NextResponse.json({ message: "سرویس احراز هویت در دسترس نیست." }, { status: 502 }); - } -} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..9da87be --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { createSession } from "@/lib/auth/session"; + +/** + * Called by the browser after it exchanges an OTP for a token directly with + * Gooyal (see lib/auth/gooyal-client.ts). This route just wraps that token in + * our encrypted httpOnly session cookie so server components / route handlers + * can attach it to Winofy API calls — it does not talk to Gooyal itself. + */ +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const { access_token, refresh_token, token_type, expires_in } = body ?? {}; + + if (typeof access_token !== "string" || typeof expires_in !== "number") { + return NextResponse.json({ message: "توکن نامعتبر است." }, { status: 400 }); + } + + await createSession({ + accessToken: access_token, + refreshToken: typeof refresh_token === "string" ? refresh_token : undefined, + tokenType: typeof token_type === "string" ? token_type : "Bearer", + expiresAt: Date.now() + expires_in * 1000, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts deleted file mode 100644 index c396777..0000000 --- a/src/app/api/auth/verify-otp/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; -import { exchangeOtpForToken, GooyalAuthError } from "@/lib/auth/gooyal"; -import { createSession } from "@/lib/auth/session"; - -export async function POST(request: Request) { - const body = await request.json().catch(() => null); - const phoneNumber = body?.phone_number; - const otp = body?.otp; - - if (!phoneNumber || !otp) { - return NextResponse.json({ message: "شماره موبایل و کد تایید الزامی است." }, { status: 400 }); - } - - try { - const token = await exchangeOtpForToken(phoneNumber, otp); - await createSession({ - accessToken: token.access_token, - refreshToken: token.refresh_token, - tokenType: token.token_type, - expiresAt: Date.now() + token.expires_in * 1000, - }); - return NextResponse.json({ ok: true }); - } catch (err) { - if (err instanceof GooyalAuthError) { - return NextResponse.json({ message: err.message, code: err.code }, { status: err.status }); - } - return NextResponse.json({ message: "سرویس احراز هویت در دسترس نیست." }, { status: 502 }); - } -} diff --git a/src/components/customer/add-to-cart-button.tsx b/src/components/customer/add-to-cart-button.tsx new file mode 100644 index 0000000..cfc6354 --- /dev/null +++ b/src/components/customer/add-to-cart-button.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { addToCart } from "@/lib/actions/cart"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils/cn"; + +export function AddToCartButton({ + productUuid, + quantity = 1, + disabled, + outOfStock, + className, +}: { + productUuid: string; + quantity?: number; + disabled?: boolean; + outOfStock?: boolean; + className?: string; +}) { + const router = useRouter(); + const pathname = usePathname(); + const [isPending, startTransition] = useTransition(); + const [feedback, setFeedback] = useState<"added" | "error" | null>(null); + + function handleClick() { + startTransition(async () => { + const result = await addToCart(productUuid, quantity); + if (result.ok) { + setFeedback("added"); + router.refresh(); + setTimeout(() => setFeedback(null), 1500); + } else if (result.status === 401) { + router.push(`/login?next=${encodeURIComponent(pathname)}`); + } else { + setFeedback("error"); + } + }); + } + + return ( + + ); +} diff --git a/src/components/customer/address-list.tsx b/src/components/customer/address-list.tsx new file mode 100644 index 0000000..981df26 --- /dev/null +++ b/src/components/customer/address-list.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { createAddress, deleteAddress, type AddressInput } from "@/lib/actions/addresses"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { Address, AddressLabel, City, Neighborhood } from "@/types/api"; + +const LABEL_OPTIONS: { value: AddressLabel; label: string }[] = [ + { value: "home", label: "خانه" }, + { value: "work", label: "محل کار" }, + { value: "other", label: "سایر" }, +]; + +const emptyForm = { + label: "home" as AddressLabel, + title: "", + full_address: "", + plaque: "", + floor: "", + unit: "", + recipient_name: "", + recipient_phone: "", + city_uuid: "", + neighborhood_uuid: "", + is_default: false, +}; + +export function AddressList({ + addresses, + cities, + neighborhoods, +}: { + addresses: Address[]; + cities: City[]; + neighborhoods: Neighborhood[]; +}) { + const router = useRouter(); + const [isAdding, setIsAdding] = useState(false); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const filteredNeighborhoods = useMemo( + () => neighborhoods.filter((n) => n.city === form.city_uuid), + [neighborhoods, form.city_uuid], + ); + + function handleDelete(uuid: string) { + startTransition(async () => { + await deleteAddress(uuid); + router.refresh(); + }); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + startTransition(async () => { + const input: AddressInput = { + ...form, + neighborhood_uuid: form.neighborhood_uuid || undefined, + latitude: 0, + longitude: 0, + }; + const result = await createAddress(input); + if (result.ok) { + setForm(emptyForm); + setIsAdding(false); + router.refresh(); + } else { + setError(result.message); + } + }); + } + + return ( +
+
+ {addresses.map((address) => ( +
+
+ + {LABEL_OPTIONS.find((o) => o.value === address.label)?.label} {address.is_default && "· پیش‌فرض"} + + {address.full_address} + + {address.recipient_name} — {address.recipient_phone} + +
+ +
+ ))} + {addresses.length === 0 && !isAdding &&

آدرسی ثبت نشده است.

} +
+ + {isAdding ? ( +
+
+ {LABEL_OPTIONS.map((opt) => ( + + ))} +
+ + setForm((f) => ({ ...f, full_address: e.target.value }))} + required + /> +
+ setForm((f) => ({ ...f, plaque: e.target.value }))} /> + setForm((f) => ({ ...f, floor: e.target.value }))} /> + setForm((f) => ({ ...f, unit: e.target.value }))} /> +
+ +
+ + +
+ + setForm((f) => ({ ...f, recipient_name: e.target.value }))} + required + /> + setForm((f) => ({ ...f, recipient_phone: e.target.value }))} + required + /> + + + + {error &&

{error}

} + +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/customer/cart-item-row.tsx b/src/components/customer/cart-item-row.tsx new file mode 100644 index 0000000..aaf8c10 --- /dev/null +++ b/src/components/customer/cart-item-row.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart"; +import { resolveClientMediaUrl } from "@/lib/api/media-client"; +import type { CartItem } from "@/types/api"; + +export function CartItemRow({ item }: { item: CartItem }) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + function changeQuantity(nextQuantity: number) { + startTransition(async () => { + if (nextQuantity <= 0) { + await removeCartItem(item.uuid); + } else { + await updateCartItemQuantity(item.uuid, nextQuantity); + } + router.refresh(); + }); + } + + return ( +
+
+ {item.product.image && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +
+
+ {item.product.name} + {item.product.price.toLocaleString("fa-IR")} تومان +
+
+ + {item.quantity} + +
+ {item.line_total.toLocaleString("fa-IR")} +
+ ); +} diff --git a/src/components/customer/checkout-form.tsx b/src/components/customer/checkout-form.tsx new file mode 100644 index 0000000..860e877 --- /dev/null +++ b/src/components/customer/checkout-form.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { submitCheckout } from "@/lib/actions/checkout"; +import { Button } from "@/components/ui/button"; +import type { Address, Cart, DeliveryType, PaymentMethod } from "@/types/api"; + +const PAYMENT_LABELS_FA: Record = { + wallet: "کیف پول وینوفی", + online: "درگاه بانکی", + cash_on_delivery: "پرداخت در محل", +}; + +export function CheckoutForm({ cart, addresses }: { cart: Cart; addresses: Address[] }) { + const router = useRouter(); + const [addressUuid, setAddressUuid] = useState(addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid ?? ""); + const [deliveryType, setDeliveryType] = useState("express"); + const [scheduledAt, setScheduledAt] = useState(""); + const [paymentMethod, setPaymentMethod] = useState("cash_on_delivery"); + const [notes, setNotes] = useState(""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!addressUuid) { + setError("لطفاً یک آدرس انتخاب کنید."); + return; + } + if (deliveryType === "scheduled" && !scheduledAt) { + setError("لطفاً زمان ارسال را مشخص کنید."); + return; + } + + startTransition(async () => { + const result = await submitCheckout({ + address_uuid: addressUuid, + delivery_type: deliveryType, + scheduled_at: deliveryType === "scheduled" ? new Date(scheduledAt).toISOString() : null, + payment_method: paymentMethod, + notes, + }); + if (result.ok) { + router.push(`/order-groups/${result.data.uuid}`); + } else { + setError(result.message); + } + }); + } + + if (addresses.length === 0) { + return ( +
+

برای ثبت سفارش ابتدا یک آدرس اضافه کنید.

+ + + +
+ ); + } + + return ( +
+
+

آدرس تحویل

+ {addresses.map((address) => ( + + ))} + + مدیریت آدرس‌ها + +
+ +
+

زمان ارسال

+
+ + +
+ {deliveryType === "scheduled" && ( + setScheduledAt(e.target.value)} + className="h-11 rounded-xl border border-border bg-background px-3 text-sm" + /> + )} +
+ +
+

روش پرداخت

+
+ {(Object.keys(PAYMENT_LABELS_FA) as PaymentMethod[]).map((method) => ( + + ))} +
+
+ +
+

توضیحات (اختیاری)

+