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 { 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 (
|
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>
|
||||||
|
|
|
||||||
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 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
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
|
// 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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue