feat: customer shopping flow (store/product pages, cart, addresses, checkout, order tracking); auth calls Gooyal directly from browser

This commit is contained in:
Afra ‌ 2026-08-17 10:19:59 +03:30
parent 6a44887111
commit d000df2e41
30 changed files with 1368 additions and 118 deletions

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { requestOtp, exchangeOtpForToken, normalizeIranianPhone, GooyalClientError } from "@/lib/auth/gooyal-client";
type Step = "phone" | "otp"; type Step = "phone" | "otp";
@ -23,16 +24,11 @@ export default function LoginPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/auth/request-otp", { // Calls Gooyal accounts directly from the browser — not our own server.
method: "POST", await requestOtp(normalizeIranianPhone(phone));
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone_number: phone }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? "خطا در ارسال کد تایید");
setStep("otp"); setStep("otp");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "خطا در ارسال کد تایید"); setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "خطا در ارسال کد تایید");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -43,17 +39,21 @@ export default function LoginPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone_number: phone, otp }), body: JSON.stringify(token),
}); });
const data = await res.json(); if (!sessionRes.ok) throw new Error("خطا در برقراری نشست ورود");
if (!res.ok) throw new Error(data.message ?? "کد تایید نامعتبر است");
router.push(next); router.push(next);
router.refresh(); router.refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "کد تایید نامعتبر است"); setError(err instanceof GooyalClientError || err instanceof Error ? err.message : "کد تایید نامعتبر است");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -20,13 +20,6 @@ export default async function HomePage() {
return ( return (
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-4 py-6"> <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"> <section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">دسته‌بندی‌ها</h2> <h2 className="text-sm font-semibold text-muted-foreground">دسته‌بندی‌ها</h2>
<div className="flex gap-3 overflow-x-auto pb-2"> <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> <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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{stores.map((store) => ( {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"> <div className="flex items-center gap-3">
{store.logo ? ( {store.logo ? (
// eslint-disable-next-line @next/next/no-img-element // 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.min_order_amount.toLocaleString("fa-IR")} تومان</span>
<span>ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان</span> <span>ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان</span>
</div> </div>
</article> </Link>
))} ))}
{stores.length === 0 && <p className="text-sm text-muted-foreground">فروشگاهی یافت نشد.</p>} {stores.length === 0 && <p className="text-sm text-muted-foreground">فروشگاهی یافت نشد.</p>}
</div> </div>

View 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>
);
}

View 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>
);
}

View file

@ -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 });
}
}

View 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 });
}

View file

@ -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 });
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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/");
}

View 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
View 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
View 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 };
}
}

View 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}`);
}

View 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;
}

View file

@ -3,7 +3,6 @@ import "server-only";
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!; const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!; const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
const CLIENT_SECRET = process.env.GOOYAL_CLIENT_SECRET!; const CLIENT_SECRET = process.env.GOOYAL_CLIENT_SECRET!;
const SCOPE = process.env.GOOYAL_OAUTH_SCOPE!;
function basicAuthHeader() { function basicAuthHeader() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); 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 { export interface GooyalTokenResponse {
access_token: string; access_token: string;
refresh_token?: string; refresh_token?: string;
@ -54,30 +33,6 @@ export interface GooyalTokenResponse {
scope: string; 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> { export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",

24
src/lib/order-status.ts Normal file
View 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: "ناموفق",
};

View file

@ -4,7 +4,16 @@ import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
// Optimistic check only (cookie presence) — real authorization happens per-request // 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. // 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) { export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;