feat: customer shopping flow (store/product pages, cart, addresses, checkout, order tracking); auth calls Gooyal directly from browser
This commit is contained in:
parent
6a44887111
commit
d000df2e41
30 changed files with 1368 additions and 118 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
32
src/app/(customer)/addresses/page.tsx
Normal file
32
src/app/(customer)/addresses/page.tsx
Normal file
|
|
@ -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<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
async function getCities() {
|
||||
const res = await winofyFetch<PaginatedResponse<City> | City[]>("/v1/cities/", { auth: false });
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
async function getNeighborhoods() {
|
||||
const res = await winofyFetch<PaginatedResponse<Neighborhood> | 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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">آدرسهای من</h1>
|
||||
<AddressList addresses={addresses} cities={cities} neighborhoods={neighborhoods} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
73
src/app/(customer)/cart/page.tsx
Normal file
73
src/app/(customer)/cart/page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<p className="text-muted-foreground">سبد خرید شما خالی است.</p>
|
||||
<Link href="/">
|
||||
<Button variant="outline">مشاهده فروشگاهها</Button>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">سبد خرید</h1>
|
||||
|
||||
{cart.groups.map((group) => (
|
||||
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||
<Link href={`/stores/${group.store.uuid}`} className="font-medium">
|
||||
{group.store.name}
|
||||
</Link>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{group.items.map((item) => (
|
||||
<CartItemRow key={item.uuid} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 text-sm">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>جمع کالاها</span>
|
||||
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>هزینه ارسال</span>
|
||||
<span>{group.delivery_fee.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>مجموع</span>
|
||||
<span>{group.total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!group.meets_minimum_order && (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="sticky bottom-4 flex flex-col gap-2 rounded-2xl border border-border bg-background p-4 shadow-lg">
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<Link href={hasBlockedGroup ? "#" : "/checkout"} aria-disabled={hasBlockedGroup}>
|
||||
<Button size="lg" className="w-full" disabled={hasBlockedGroup}>
|
||||
ادامه و ثبت سفارش
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
src/app/(customer)/checkout/page.tsx
Normal file
23
src/app/(customer)/checkout/page.tsx
Normal file
|
|
@ -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<PaginatedResponse<Address> | 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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">تکمیل سفارش</h1>
|
||||
<CheckoutForm cart={cart} addresses={addresses} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
19
src/app/(customer)/error.tsx
Normal file
19
src/app/(customer)/error.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<p className="text-sm text-muted-foreground">{error.message || "خطایی رخ داد. لطفاً دوباره تلاش کنید."}</p>
|
||||
<Button variant="outline" onClick={reset}>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
42
src/app/(customer)/layout.tsx
Normal file
42
src/app/(customer)/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-3">
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
وینوفی
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
{session && (
|
||||
<>
|
||||
<Link href="/orders" className="text-muted-foreground">
|
||||
سفارشها
|
||||
</Link>
|
||||
<Link href="/addresses" className="text-muted-foreground">
|
||||
آدرسها
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link href="/cart" className="text-muted-foreground">
|
||||
سبد خرید
|
||||
</Link>
|
||||
{session ? (
|
||||
<LogoutButton />
|
||||
) : (
|
||||
<Link href="/login" className="text-primary underline underline-offset-4">
|
||||
ورود
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/app/(customer)/order-groups/[uuid]/page.tsx
Normal file
53
src/app/(customer)/order-groups/[uuid]/page.tsx
Normal file
|
|
@ -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<OrderGroup>(`/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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<div className="flex flex-col items-center gap-2 rounded-2xl border border-border p-6 text-center">
|
||||
<span className="text-2xl">✓</span>
|
||||
<h1 className="text-lg font-bold">سفارش شما ثبت شد</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
وضعیت پرداخت: {PAYMENT_STATUS_LABELS_FA[orderGroup.payment_status]}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{orderGroup.full_address}</p>
|
||||
</div>
|
||||
|
||||
{orderGroup.payment?.payment_url && (
|
||||
<a
|
||||
href={orderGroup.payment.payment_url}
|
||||
className="rounded-xl bg-primary px-4 py-3 text-center text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
ادامه پرداخت
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderGroup.orders.map((order) => (
|
||||
<OrderCard key={order.uuid} order={order} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between rounded-2xl border border-border p-4 font-semibold">
|
||||
<span>مجموع کل</span>
|
||||
<span>{orderGroup.total_amount.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
47
src/app/(customer)/orders/page.tsx
Normal file
47
src/app/(customer)/orders/page.tsx
Normal file
|
|
@ -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<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export default async function OrdersPage() {
|
||||
const orderGroups = await getOrderGroups();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">سفارشهای من</h1>
|
||||
|
||||
{orderGroups.length === 0 && <p className="text-sm text-muted-foreground">هنوز سفارشی ثبت نکردهاید.</p>}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{orderGroups.map((group) => (
|
||||
<Link
|
||||
key={group.uuid}
|
||||
href={`/order-groups/${group.uuid}`}
|
||||
className="flex flex-col gap-2 rounded-2xl border border-border p-4"
|
||||
>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{new Date(group.created_at).toLocaleDateString("fa-IR")}</span>
|
||||
<span>{PAYMENT_STATUS_LABELS_FA[group.payment_status]}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-sm">
|
||||
{group.orders.map((order) => (
|
||||
<span key={order.uuid} className="rounded-full bg-muted px-2 py-0.5">
|
||||
{order.store.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>مجموع</span>
|
||||
<span>{group.total_amount.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,13 +20,6 @@ export default async function HomePage() {
|
|||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-4 py-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">وینوفی</h1>
|
||||
<Link href="/login" className="text-sm text-primary underline underline-offset-4">
|
||||
ورود
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">دستهبندیها</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
|
|
@ -50,7 +43,11 @@ export default async function HomePage() {
|
|||
<h2 className="text-sm font-semibold text-muted-foreground">فروشگاههای نزدیک شما</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{stores.map((store) => (
|
||||
<article key={store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||
<Link
|
||||
href={`/stores/${store.uuid}`}
|
||||
key={store.uuid}
|
||||
className="flex flex-col gap-2 rounded-2xl border border-border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{store.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
|
@ -75,7 +72,7 @@ export default async function HomePage() {
|
|||
<span>حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان</span>
|
||||
<span>ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
{stores.length === 0 && <p className="text-sm text-muted-foreground">فروشگاهی یافت نشد.</p>}
|
||||
</div>
|
||||
|
|
|
|||
54
src/app/(customer)/products/[uuid]/page.tsx
Normal file
54
src/app/(customer)/products/[uuid]/page.tsx
Normal file
|
|
@ -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<string, string> = { gram: "گرم", ml: "میلیلیتر", piece: "عدد" };
|
||||
|
||||
async function getProduct(uuid: string) {
|
||||
try {
|
||||
return await winofyFetch<ProductDetail>(`/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 (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
|
||||
<Link href={`/stores/${product.store.uuid}`} className="text-sm text-primary underline underline-offset-4">
|
||||
← بازگشت به {product.store.name}
|
||||
</Link>
|
||||
|
||||
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
|
||||
{product.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveMediaUrl(product.image) ?? undefined} alt={product.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-xl font-bold">{product.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{product.unit_value} {UNIT_LABELS_FA[product.unit_type] ?? product.unit_type}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
|
||||
{product.description && <p className="text-sm text-muted-foreground">{product.description}</p>}
|
||||
</div>
|
||||
|
||||
<AddToCartButton
|
||||
productUuid={product.uuid}
|
||||
disabled={!product.is_active || product.is_out_of_stock}
|
||||
outOfStock={product.is_out_of_stock}
|
||||
className="w-full"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
136
src/app/(customer)/stores/[uuid]/page.tsx
Normal file
136
src/app/(customer)/stores/[uuid]/page.tsx
Normal file
|
|
@ -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<StoreDetail>(`/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<PaginatedResponse<ProductListItem>>("/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 (
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-border">
|
||||
<div className="h-32 w-full bg-muted">
|
||||
{store.cover_image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveMediaUrl(store.cover_image) ?? undefined} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{store.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveMediaUrl(store.logo) ?? undefined}
|
||||
alt={store.name}
|
||||
className="h-16 w-16 shrink-0 rounded-full border border-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 shrink-0 rounded-full bg-muted" />
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{store.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{store.category.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count} نظر) ·{" "}
|
||||
{store.is_open ? "باز" : "بسته"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{store.description && <p className="text-sm text-muted-foreground">{store.description}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-3 py-1">
|
||||
حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-3 py-1">
|
||||
هزینه ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
{store.free_delivery_threshold != null && (
|
||||
<span className="rounded-full bg-muted px-3 py-1">
|
||||
ارسال رایگان بالای {store.free_delivery_threshold.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{store.accepts_wallet && <span className="rounded-full border border-border px-3 py-1">کیف پول</span>}
|
||||
{store.accepts_online && <span className="rounded-full border border-border px-3 py-1">درگاه بانکی</span>}
|
||||
{store.accepts_cash_on_delivery && (
|
||||
<span className="rounded-full border border-border px-3 py-1">پرداخت در محل</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{store.working_hours?.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer font-medium">ساعات کاری</summary>
|
||||
<ul className="mt-2 flex flex-col gap-1 text-muted-foreground">
|
||||
{store.working_hours.map((wh) => (
|
||||
<li key={wh.weekday} className="flex justify-between">
|
||||
<span>{WEEKDAY_LABELS_FA[wh.weekday]}</span>
|
||||
<span>{wh.is_closed ? "تعطیل" : `${wh.opens_at} تا ${wh.closes_at}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">محصولات</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<article key={product.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-3">
|
||||
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
|
||||
{product.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveMediaUrl(product.image) ?? undefined}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="line-clamp-2 text-sm font-medium">{product.name}</h3>
|
||||
<p className="text-sm font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
|
||||
</Link>
|
||||
<AddToCartButton
|
||||
productUuid={product.uuid}
|
||||
disabled={!product.is_active || product.is_out_of_stock}
|
||||
outOfStock={product.is_out_of_stock}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
{products.length === 0 && <p className="text-sm text-muted-foreground">محصولی یافت نشد.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
26
src/app/api/auth/session/route.ts
Normal file
26
src/app/api/auth/session/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
54
src/components/customer/add-to-cart-button.tsx
Normal file
54
src/components/customer/add-to-cart-button.tsx
Normal file
|
|
@ -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 (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={feedback === "added" ? "secondary" : "primary"}
|
||||
disabled={disabled || isPending}
|
||||
onClick={handleClick}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
{outOfStock ? "ناموجود" : isPending ? "..." : feedback === "added" ? "افزوده شد ✓" : feedback === "error" ? "خطا، دوباره تلاش کنید" : "افزودن به سبد"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
198
src/components/customer/address-list.tsx
Normal file
198
src/components/customer/address-list.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{addresses.map((address) => (
|
||||
<div key={address.uuid} className="flex items-start justify-between gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{LABEL_OPTIONS.find((o) => o.value === address.label)?.label} {address.is_default && "· پیشفرض"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{address.full_address}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{address.recipient_name} — {address.recipient_phone}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled={isPending} onClick={() => handleDelete(address.uuid)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{addresses.length === 0 && !isAdding && <p className="text-sm text-muted-foreground">آدرسی ثبت نشده است.</p>}
|
||||
</div>
|
||||
|
||||
{isAdding ? (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex gap-2">
|
||||
{LABEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, label: opt.value }))}
|
||||
className={`rounded-full px-3 py-1 text-xs ${form.label === opt.value ? "bg-primary text-primary-foreground" : "border border-border"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="آدرس کامل"
|
||||
value={form.full_address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, full_address: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input placeholder="پلاک" value={form.plaque} onChange={(e) => setForm((f) => ({ ...f, plaque: e.target.value }))} />
|
||||
<Input placeholder="طبقه" value={form.floor} onChange={(e) => setForm((f) => ({ ...f, floor: e.target.value }))} />
|
||||
<Input placeholder="واحد" value={form.unit} onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={form.city_uuid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, city_uuid: e.target.value, neighborhood_uuid: "" }))}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value="">شهر</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city.uuid} value={city.uuid}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.neighborhood_uuid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, neighborhood_uuid: e.target.value }))}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
disabled={!form.city_uuid}
|
||||
>
|
||||
<option value="">محله (اختیاری)</option>
|
||||
{filteredNeighborhoods.map((n) => (
|
||||
<option key={n.uuid} value={n.uuid}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="نام گیرنده"
|
||||
value={form.recipient_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recipient_name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
placeholder="09123456789"
|
||||
value={form.recipient_phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recipient_phone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm((f) => ({ ...f, is_default: e.target.checked }))}
|
||||
/>
|
||||
آدرس پیشفرض
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "در حال ثبت..." : "ثبت آدرس"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setIsAdding(true)}>
|
||||
+ افزودن آدرس جدید
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/components/customer/cart-item-row.tsx
Normal file
60
src/components/customer/cart-item-row.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{item.product.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolveClientMediaUrl(item.product.image) ?? undefined} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-sm font-medium">{item.product.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity - 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-5 text-center text-sm">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/customer/checkout-form.tsx
Normal file
158
src/components/customer/checkout-form.tsx
Normal file
|
|
@ -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<PaymentMethod, string> = {
|
||||
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<DeliveryType>("express");
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("cash_on_delivery");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">برای ثبت سفارش ابتدا یک آدرس اضافه کنید.</p>
|
||||
<Link href="/addresses">
|
||||
<Button>افزودن آدرس</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">آدرس تحویل</h2>
|
||||
{addresses.map((address) => (
|
||||
<label
|
||||
key={address.uuid}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-2xl border p-3 text-sm ${addressUuid === address.uuid ? "border-primary" : "border-border"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="address"
|
||||
checked={addressUuid === address.uuid}
|
||||
onChange={() => setAddressUuid(address.uuid)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{address.title || address.full_address}</span>
|
||||
<span className="block text-muted-foreground">{address.full_address}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<Link href="/addresses" className="text-xs text-primary underline underline-offset-4">
|
||||
مدیریت آدرسها
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">زمان ارسال</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryType("express")}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm ${deliveryType === "express" ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
ارسال فوری
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryType("scheduled")}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm ${deliveryType === "scheduled" ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
زمانبندی شده
|
||||
</button>
|
||||
</div>
|
||||
{deliveryType === "scheduled" && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">روش پرداخت</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(PAYMENT_LABELS_FA) as PaymentMethod[]).map((method) => (
|
||||
<label
|
||||
key={method}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-2xl border p-3 text-sm ${paymentMethod === method ? "border-primary" : "border-border"}`}
|
||||
>
|
||||
<input type="radio" name="payment" checked={paymentMethod === method} onChange={() => setPaymentMethod(method)} />
|
||||
{PAYMENT_LABELS_FA[method]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">توضیحات (اختیاری)</h2>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between rounded-2xl border border-border p-4 font-semibold">
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" disabled={isPending}>
|
||||
{isPending ? "در حال ثبت سفارش..." : "ثبت نهایی سفارش"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
23
src/components/customer/logout-button.tsx
Normal file
23
src/components/customer/logout-button.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleLogout() {
|
||||
startTransition(async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleLogout} disabled={isPending} className="text-muted-foreground">
|
||||
خروج
|
||||
</button>
|
||||
);
|
||||
}
|
||||
76
src/components/customer/order-card.tsx
Normal file
76
src/components/customer/order-card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cancelOrder } from "@/lib/actions/orders";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ORDER_STATUS_LABELS_FA, ORDER_STATUS_SEQUENCE } from "@/lib/order-status";
|
||||
import type { Order } from "@/types/api";
|
||||
|
||||
export function OrderCard({ order }: { order: Order }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isCancelled = order.status === "cancelled";
|
||||
const currentStepIndex = ORDER_STATUS_SEQUENCE.indexOf(order.status);
|
||||
|
||||
function handleCancel() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await cancelOrder(order.uuid);
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{order.store.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{ORDER_STATUS_LABELS_FA[order.status]}</span>
|
||||
</div>
|
||||
|
||||
{!isCancelled && (
|
||||
<div className="flex items-center gap-1">
|
||||
{ORDER_STATUS_SEQUENCE.map((step, i) => (
|
||||
<div key={step} className={`h-1.5 flex-1 rounded-full ${i <= currentStepIndex ? "bg-primary" : "bg-muted"}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.uuid} className="flex justify-between">
|
||||
<span>
|
||||
{item.product_name_snapshot} × {item.quantity}
|
||||
</span>
|
||||
<span>{item.line_total.toLocaleString("fa-IR")} تومان</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{order.courier_name && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
پیک: {order.courier_name} — {order.courier_phone}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between border-t border-border pt-2 text-sm font-semibold">
|
||||
<span>مجموع</span>
|
||||
<span>{order.total_amount.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{order.is_cancellable && (
|
||||
<Button variant="outline" size="sm" disabled={isPending} onClick={handleCancel}>
|
||||
{isPending ? "در حال لغو..." : "لغو سفارش"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/lib/actions/addresses.ts
Normal file
44
src/lib/actions/addresses.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Address, AddressLabel } from "@/types/api";
|
||||
|
||||
export interface AddressInput {
|
||||
label: AddressLabel;
|
||||
title: string;
|
||||
full_address: string;
|
||||
plaque: string;
|
||||
floor: string;
|
||||
unit: string;
|
||||
recipient_name: string;
|
||||
recipient_phone: string;
|
||||
city_uuid: string;
|
||||
neighborhood_uuid?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export async function createAddress(input: AddressInput): Promise<ActionResult<Address>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/addresses");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateAddress(uuid: string, input: Partial<AddressInput>): Promise<ActionResult<Address>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Address>(`/v1/addresses/${uuid}/`, { method: "PATCH", body: input }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/addresses");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteAddress(uuid: string): Promise<ActionResult> {
|
||||
const result = await callApi(() => winofyFetch<undefined>(`/v1/addresses/${uuid}/`, { method: "DELETE" }));
|
||||
if (result.ok) revalidatePath("/addresses");
|
||||
return result;
|
||||
}
|
||||
44
src/lib/actions/cart.ts
Normal file
44
src/lib/actions/cart.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Cart } from "@/types/api";
|
||||
|
||||
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<undefined>("/v1/cart/items/", {
|
||||
method: "POST",
|
||||
body: { product_uuid: productUuid, quantity },
|
||||
}),
|
||||
);
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise<ActionResult> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<undefined>(`/v1/cart/items/${itemUuid}/`, {
|
||||
method: "PATCH",
|
||||
body: { quantity },
|
||||
}),
|
||||
);
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function removeCartItem(itemUuid: string): Promise<ActionResult> {
|
||||
const result = await callApi(() => winofyFetch<undefined>(`/v1/cart/items/${itemUuid}/`, { method: "DELETE" }));
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function clearCart(): Promise<ActionResult> {
|
||||
const result = await callApi(() => winofyFetch<undefined>("/v1/cart/", { method: "DELETE" }));
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCart(): Promise<Cart> {
|
||||
return winofyFetch<Cart>("/v1/cart/");
|
||||
}
|
||||
17
src/lib/actions/checkout.ts
Normal file
17
src/lib/actions/checkout.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"use server";
|
||||
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api";
|
||||
|
||||
export interface CheckoutInput {
|
||||
address_uuid: string;
|
||||
delivery_type: DeliveryType;
|
||||
scheduled_at: string | null;
|
||||
payment_method: PaymentMethod;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export async function submitCheckout(input: CheckoutInput): Promise<ActionResult<OrderGroup>> {
|
||||
return callApi(() => winofyFetch<OrderGroup>("/v1/checkout/", { method: "POST", body: input }));
|
||||
}
|
||||
17
src/lib/actions/orders.ts
Normal file
17
src/lib/actions/orders.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Order } from "@/types/api";
|
||||
|
||||
export async function cancelOrder(uuid: string, reason: string = ""): Promise<ActionResult<Order>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { reason } }),
|
||||
);
|
||||
if (result.ok) {
|
||||
revalidatePath(`/orders/${uuid}`);
|
||||
revalidatePath("/orders");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
18
src/lib/actions/result.ts
Normal file
18
src/lib/actions/result.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import "server-only";
|
||||
import { ApiError } from "@/lib/api/winofy";
|
||||
|
||||
export type ActionResult<T = undefined> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; status: number; message: string; fieldErrors: Record<string, string[]> | null };
|
||||
|
||||
export async function callApi<T>(fn: () => Promise<T>): Promise<ActionResult<T>> {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
return { ok: false, status: err.status, message: err.message, fieldErrors: err.fieldErrors };
|
||||
}
|
||||
return { ok: false, status: 500, message: "خطای غیرمنتظره رخ داد.", fieldErrors: null };
|
||||
}
|
||||
}
|
||||
8
src/lib/api/media-client.ts
Normal file
8
src/lib/api/media-client.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const MEDIA_ORIGIN = process.env.NEXT_PUBLIC_WINOFY_MEDIA_ORIGIN!;
|
||||
|
||||
/** Client-safe counterpart to lib/api/media.ts's resolveMediaUrl. */
|
||||
export function resolveClientMediaUrl(path: string | null): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return MEDIA_ORIGIN + (path.startsWith("/") ? path : `/${path}`);
|
||||
}
|
||||
93
src/lib/auth/gooyal-client.ts
Normal file
93
src/lib/auth/gooyal-client.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Browser-side calls straight to Gooyal accounts (CORS is open there).
|
||||
* The client_id/secret are intentionally public here — see .env.local comment.
|
||||
*/
|
||||
|
||||
const ACCOUNTS_BASE_URL = process.env.NEXT_PUBLIC_GOOYAL_ACCOUNTS_BASE_URL!;
|
||||
const CLIENT_ID = process.env.NEXT_PUBLIC_GOOYAL_CLIENT_ID!;
|
||||
const CLIENT_SECRET = process.env.NEXT_PUBLIC_GOOYAL_CLIENT_SECRET!;
|
||||
const SCOPE = process.env.NEXT_PUBLIC_GOOYAL_OAUTH_SCOPE!;
|
||||
|
||||
function basicAuthHeader() {
|
||||
return "Basic " + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`);
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES_FA: Record<string, string> = {
|
||||
invalid_grant: "کد تایید نامعتبر یا منقضی شده است.",
|
||||
invalid_client: "خطا در پیکربندی سرویس احراز هویت. لطفاً بعداً تلاش کنید.",
|
||||
invalid_request: "درخواست نامعتبر است.",
|
||||
};
|
||||
|
||||
export class GooyalClientError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
description?: string,
|
||||
) {
|
||||
super(ERROR_MESSAGES_FA[code] ?? description ?? code);
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts local Iranian input (09..., 9..., 0098...) and normalizes to Gooyal's required +989999999999. */
|
||||
export function normalizeIranianPhone(input: string): string {
|
||||
const digits = input.replace(/\D/g, "");
|
||||
const local = digits.replace(/^0098/, "").replace(/^98/, "").replace(/^0/, "");
|
||||
return `+98${local}`;
|
||||
}
|
||||
|
||||
function extractWrappedErrorMessage(data: unknown): string | undefined {
|
||||
const details = (data as { details?: { message?: string | Record<string, string[]> } })?.details;
|
||||
if (!details) return undefined;
|
||||
if (typeof details.message === "string") return details.message;
|
||||
if (details.message && typeof details.message === "object") {
|
||||
const first = Object.values(details.message)[0];
|
||||
return Array.isArray(first) ? first[0] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function requestOtp(phoneNumber: string) {
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/users/api/request_otp/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone_number: phoneNumber }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new GooyalClientError(res.status, "request_otp_failed", extractWrappedErrorMessage(data));
|
||||
}
|
||||
return data as { phone_number: string; otp_expire: string; ttl: number };
|
||||
}
|
||||
|
||||
export interface GooyalTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export async function exchangeOtpForToken(phoneNumber: string, otp: string) {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "password",
|
||||
username: phoneNumber,
|
||||
password: otp,
|
||||
scope: SCOPE,
|
||||
auth_fields: "phone_number:otp",
|
||||
});
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
||||
}
|
||||
return data as GooyalTokenResponse;
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import "server-only";
|
|||
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
|
||||
const CLIENT_SECRET = process.env.GOOYAL_CLIENT_SECRET!;
|
||||
const SCOPE = process.env.GOOYAL_OAUTH_SCOPE!;
|
||||
|
||||
function basicAuthHeader() {
|
||||
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
|
||||
|
|
@ -26,26 +25,6 @@ export class GooyalAuthError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export interface RequestOtpResult {
|
||||
phone_number: string;
|
||||
otp_expire: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
export async function requestOtp(phoneNumber: string): Promise<RequestOtpResult> {
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/users/api/request_otp/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone_number: phoneNumber }),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new GooyalAuthError(res.status, data.error ?? "request_otp_failed", data.error_description ?? data.detail);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface GooyalTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
|
|
@ -54,30 +33,6 @@ export interface GooyalTokenResponse {
|
|||
scope: string;
|
||||
}
|
||||
|
||||
export async function exchangeOtpForToken(phoneNumber: string, otp: string): Promise<GooyalTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "password",
|
||||
username: phoneNumber,
|
||||
password: otp,
|
||||
scope: SCOPE,
|
||||
auth_fields: "phone_number:otp",
|
||||
});
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: body.toString(),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new GooyalAuthError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
|
|
|
|||
24
src/lib/order-status.ts
Normal file
24
src/lib/order-status.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { OrderStatus, PaymentStatus } from "@/types/api";
|
||||
|
||||
export const ORDER_STATUS_LABELS_FA: Record<OrderStatus, string> = {
|
||||
placed: "سفارش ثبت شد",
|
||||
preparing: "در حال آمادهسازی",
|
||||
ready_to_ship: "آماده ارسال",
|
||||
handed_to_courier: "تحویل پیک شد",
|
||||
delivered: "تحویل داده شد",
|
||||
cancelled: "لغو شده",
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_SEQUENCE: OrderStatus[] = [
|
||||
"placed",
|
||||
"preparing",
|
||||
"ready_to_ship",
|
||||
"handed_to_courier",
|
||||
"delivered",
|
||||
];
|
||||
|
||||
export const PAYMENT_STATUS_LABELS_FA: Record<PaymentStatus, string> = {
|
||||
pending: "در انتظار پرداخت",
|
||||
paid: "پرداخت شده",
|
||||
failed: "ناموفق",
|
||||
};
|
||||
11
src/proxy.ts
11
src/proxy.ts
|
|
@ -4,7 +4,16 @@ import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
|
|||
|
||||
// Optimistic check only (cookie presence) — real authorization happens per-request
|
||||
// against the Winofy API, which is the source of truth. See FRONTEND_GUIDE.md §4.3.
|
||||
const AUTH_REQUIRED_PREFIXES = ["/seller", "/cart", "/checkout", "/orders", "/account", "/notifications"];
|
||||
const AUTH_REQUIRED_PREFIXES = [
|
||||
"/seller",
|
||||
"/cart",
|
||||
"/checkout",
|
||||
"/orders",
|
||||
"/order-groups",
|
||||
"/addresses",
|
||||
"/account",
|
||||
"/notifications",
|
||||
];
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue