feat: auth flow (Gooyal OTP/OAuth2) + customer home page against live staging API
- Session stored as an encrypted (JWE) httpOnly cookie; access/refresh tokens never reach the client, client_secret never leaves the server. - winofyFetch: server-side API client matching FRONTEND_GUIDE.md's plain-success / wrapped-error (§4.1-4.2) convention, with auto token refresh. - Login page (phone -> OTP) wired to Gooyal accounts staging; request_otp verified working end-to-end. Token exchange returns invalid_client with the client_id/secret currently on hand — needs a fix from whoever issued them. - proxy.ts (Next 16's renamed middleware) gates seller/cart/checkout/orders routes, verified redirecting unauthenticated requests to /login. - Customer home page renders real store/category data fetched live from winofy-staging.winsoo.ir.
This commit is contained in:
parent
4e1b6f8bad
commit
6a44887111
22 changed files with 1860 additions and 95 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
turbopack: {
|
||||||
|
root: __dirname,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
979
package-lock.json
generated
979
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -9,9 +9,23 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-toast": "^1.2.23",
|
||||||
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"jose": "^6.2.9",
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
121
src/app/(auth)/login/page.tsx
Normal file
121
src/app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type Step = "phone" | "otp";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const next = searchParams.get("next") ?? "/";
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("phone");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleRequestOtp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/request-otp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone_number: phone }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message ?? "خطا در ارسال کد تایید");
|
||||||
|
setStep("otp");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "خطا در ارسال کد تایید");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyOtp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/verify-otp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone_number: phone, otp }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message ?? "کد تایید نامعتبر است");
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "کد تایید نامعتبر است");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-full w-full max-w-sm flex-1 flex-col justify-center gap-6 px-6 py-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold">ورود به وینوفی</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{step === "phone"
|
||||||
|
? "شماره موبایل خود را وارد کنید"
|
||||||
|
: `کد ارسال شده به ${phone} را وارد کنید`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "phone" ? (
|
||||||
|
<form onSubmit={handleRequestOtp} className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="09123456789"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
required
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-center text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" disabled={loading} size="lg">
|
||||||
|
{loading ? "در حال ارسال..." : "دریافت کد تایید"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleVerifyOtp} className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="12345"
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
required
|
||||||
|
className="text-center tracking-[0.5em]"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-center text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" disabled={loading} size="lg">
|
||||||
|
{loading ? "در حال بررسی..." : "تایید و ورود"}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("phone");
|
||||||
|
setOtp("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="text-sm text-muted-foreground underline underline-offset-4"
|
||||||
|
>
|
||||||
|
ویرایش شماره موبایل
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/app/(customer)/page.tsx
Normal file
85
src/app/(customer)/page.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
|
||||||
|
import { resolveMediaUrl } from "@/lib/api/media";
|
||||||
|
import type { StoreCategory, StoreListItem } from "@/types/api";
|
||||||
|
|
||||||
|
async function getStoreCategories() {
|
||||||
|
const res = await winofyFetch<PaginatedResponse<StoreCategory> | StoreCategory[]>("/v1/store-categories/", {
|
||||||
|
auth: false,
|
||||||
|
});
|
||||||
|
return Array.isArray(res) ? res : res.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStores() {
|
||||||
|
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/?limit=12", { auth: false });
|
||||||
|
return res.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const [categories, stores] = await Promise.all([getStoreCategories(), getStores()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-8 px-4 py-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">وینوفی</h1>
|
||||||
|
<Link href="/login" className="text-sm text-primary underline underline-offset-4">
|
||||||
|
ورود
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground">دستهبندیها</h2>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category.uuid}
|
||||||
|
className="flex shrink-0 flex-col items-center gap-2 rounded-2xl border border-border px-4 py-3"
|
||||||
|
>
|
||||||
|
{category.icon && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={resolveMediaUrl(category.icon) ?? undefined} alt="" className="h-8 w-8" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs">{category.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{categories.length === 0 && <p className="text-sm text-muted-foreground">دستهبندیای یافت نشد.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground">فروشگاههای نزدیک شما</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
{stores.map((store) => (
|
||||||
|
<article key={store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||||
|
<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-12 w-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-12 w-12 rounded-full bg-muted" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">{store.name}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{store.category.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count})</span>
|
||||||
|
<span>{store.is_open ? "باز" : "بسته"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span>حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان</span>
|
||||||
|
<span>ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{stores.length === 0 && <p className="text-sm text-muted-foreground">فروشگاهی یافت نشد.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { deleteSession } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await deleteSession();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
21
src/app/api/auth/request-otp/route.ts
Normal file
21
src/app/api/auth/request-otp/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/auth/verify-otp/route.ts
Normal file
29
src/app/api/auth/verify-otp/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,52 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Placeholder tokens — TODO replace with real values pulled from the Figma
|
||||||
|
* design-system page (get_variable_defs) once file access is granted.
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
|
--primary: #16a34a;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #f1f5f9;
|
||||||
|
--secondary-foreground: #171717;
|
||||||
|
--muted: #f1f5f9;
|
||||||
|
--muted-foreground: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-primary: var(--primary);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--font-sans: var(--font-vazirmatn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
|
--secondary: #1f2937;
|
||||||
|
--secondary-foreground: #ededed;
|
||||||
|
--muted: #1f2937;
|
||||||
|
--muted-foreground: #9ca3af;
|
||||||
|
--border: #27272a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans), system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,24 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Vazirmatn } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { QueryProvider } from "@/lib/query-provider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const vazirmatn = Vazirmatn({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-vazirmatn",
|
||||||
subsets: ["latin"],
|
subsets: ["arabic"],
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "وینوفی",
|
||||||
description: "Generated by create next app",
|
description: "خرید آنلاین از فروشگاههای محله شما",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`}>
|
||||||
lang="en"
|
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
<QueryProvider>{children}</QueryProvider>
|
||||||
>
|
</body>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert h-5 w-[100px]"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the{" "}
|
|
||||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
|
||||||
page.tsx
|
|
||||||
</code>{" "}
|
|
||||||
file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert h-[14px] w-4"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={14}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
39
src/components/ui/button.tsx
Normal file
39
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-xl text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline: "border border-border bg-transparent hover:bg-muted",
|
||||||
|
ghost: "bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-11 px-4",
|
||||||
|
sm: "h-9 px-3 text-xs",
|
||||||
|
lg: "h-12 px-6 text-base",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "primary",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||||
|
}
|
||||||
16
src/components/ui/input.tsx
Normal file
16
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-xl border border-border bg-background px-4 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
31
src/lib/api/errors.ts
Normal file
31
src/lib/api/errors.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/** Winofy error envelope, per FRONTEND_GUIDE.md §4.2 */
|
||||||
|
export interface WinofyErrorDetails {
|
||||||
|
message: string | Record<string, string[]>;
|
||||||
|
error?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public statusMessage: string,
|
||||||
|
public details: WinofyErrorDetails | null,
|
||||||
|
) {
|
||||||
|
super(ApiError.extractMessage(details) ?? statusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static extractMessage(details: WinofyErrorDetails | null): string | null {
|
||||||
|
if (!details) return null;
|
||||||
|
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] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field-level validation errors, if the envelope's message was keyed by field name. */
|
||||||
|
get fieldErrors(): Record<string, string[]> | null {
|
||||||
|
return this.details && typeof this.details.message === "object" ? this.details.message : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/lib/api/media.ts
Normal file
10
src/lib/api/media.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
const API_ORIGIN = new URL(process.env.WINOFY_API_BASE_URL!).origin;
|
||||||
|
|
||||||
|
/** Store/product image fields are `null` or an absolute/relative media URL — see FRONTEND_GUIDE.md §4.5. */
|
||||||
|
export function resolveMediaUrl(path: string | null): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||||
|
return API_ORIGIN + (path.startsWith("/") ? path : `/${path}`);
|
||||||
|
}
|
||||||
84
src/lib/api/winofy.ts
Normal file
84
src/lib/api/winofy.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import "server-only";
|
||||||
|
import { getSession, createSession } from "@/lib/auth/session";
|
||||||
|
import { refreshAccessToken } from "@/lib/auth/gooyal";
|
||||||
|
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
|
||||||
|
|
||||||
|
interface WinofyFetchOptions extends Omit<RequestInit, "body"> {
|
||||||
|
/** Attach the caller's bearer token. Defaults to true — set false for public endpoints. */
|
||||||
|
auth?: boolean;
|
||||||
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getValidAccessToken(): Promise<string | null> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
// 30s skew so we don't hand out a token that expires mid-request.
|
||||||
|
if (Date.now() < session.expiresAt - 30_000) return session.accessToken;
|
||||||
|
if (!session.refreshToken) return session.accessToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||||
|
await createSession({
|
||||||
|
accessToken: refreshed.access_token,
|
||||||
|
refreshToken: refreshed.refresh_token ?? session.refreshToken,
|
||||||
|
tokenType: refreshed.token_type,
|
||||||
|
expiresAt: Date.now() + refreshed.expires_in * 1000,
|
||||||
|
});
|
||||||
|
return refreshed.access_token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function winofyFetch<T>(path: string, options: WinofyFetchOptions = {}): Promise<T> {
|
||||||
|
const { auth = true, params, headers, body, ...rest } = options;
|
||||||
|
|
||||||
|
const url = new URL(BASE_URL.replace(/\/$/, "") + path);
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined) url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalHeaders = new Headers(headers);
|
||||||
|
const hasBody = body !== undefined;
|
||||||
|
if (hasBody && !finalHeaders.has("Content-Type")) {
|
||||||
|
finalHeaders.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
if (token) finalHeaders.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
...rest,
|
||||||
|
headers: finalHeaders,
|
||||||
|
body: hasBody ? JSON.stringify(body) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||||
|
throw new ApiError(res.status, envelope?.status_message ?? res.statusText, envelope?.details ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
100
src/lib/auth/gooyal.ts
Normal file
100
src/lib/auth/gooyal.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||||
|
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
|
||||||
|
const CLIENT_SECRET = process.env.GOOYAL_CLIENT_SECRET!;
|
||||||
|
const SCOPE = process.env.GOOYAL_OAUTH_SCOPE!;
|
||||||
|
|
||||||
|
function basicAuthHeader() {
|
||||||
|
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_MESSAGES_FA: Record<string, string> = {
|
||||||
|
invalid_grant: "کد تایید نامعتبر یا منقضی شده است.",
|
||||||
|
invalid_client: "خطا در پیکربندی سرویس احراز هویت. لطفاً بعداً تلاش کنید.",
|
||||||
|
invalid_request: "درخواست نامعتبر است.",
|
||||||
|
unsupported_grant_type: "خطا در پیکربندی سرویس احراز هویت.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GooyalAuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public code: string,
|
||||||
|
description?: string,
|
||||||
|
) {
|
||||||
|
super(ERROR_MESSAGES_FA[code] ?? description ?? code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOtpResult {
|
||||||
|
phone_number: string;
|
||||||
|
otp_expire: string;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOtp(phoneNumber: string): Promise<RequestOtpResult> {
|
||||||
|
const res = await fetch(`${ACCOUNTS_BASE_URL}/users/api/request_otp/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone_number: phoneNumber }),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new GooyalAuthError(res.status, data.error ?? "request_otp_failed", data.error_description ?? data.detail);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GooyalTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeOtpForToken(phoneNumber: string, otp: string): Promise<GooyalTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "password",
|
||||||
|
username: phoneNumber,
|
||||||
|
password: otp,
|
||||||
|
scope: SCOPE,
|
||||||
|
auth_fields: "phone_number:otp",
|
||||||
|
});
|
||||||
|
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: basicAuthHeader(),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new GooyalAuthError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
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 ?? "refresh_failed", data.error_description);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
53
src/lib/auth/session.ts
Normal file
53
src/lib/auth/session.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import "server-only";
|
||||||
|
import { EncryptJWT, jwtDecrypt } from "jose";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const secret = process.env.SESSION_SECRET;
|
||||||
|
if (!secret) throw new Error("SESSION_SECRET is not set");
|
||||||
|
const encodedKey = new Uint8Array(Buffer.from(secret, "base64"));
|
||||||
|
|
||||||
|
const COOKIE_NAME = "winofy_session";
|
||||||
|
|
||||||
|
export interface SessionTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenType: string;
|
||||||
|
/** epoch ms */
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(tokens: SessionTokens) {
|
||||||
|
const jwe = await new EncryptJWT({ ...tokens })
|
||||||
|
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("30d")
|
||||||
|
.encrypt(encodedKey);
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(COOKIE_NAME, jwe, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<SessionTokens | null> {
|
||||||
|
const store = await cookies();
|
||||||
|
const value = store.get(COOKIE_NAME)?.value;
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtDecrypt(value, encodedKey);
|
||||||
|
return payload as unknown as SessionTokens;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession() {
|
||||||
|
const store = await cookies();
|
||||||
|
store.delete(COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_COOKIE_NAME = COOKIE_NAME;
|
||||||
20
src/lib/query-provider.tsx
Normal file
20
src/lib/query-provider.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [client] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
6
src/lib/utils/cn.ts
Normal file
6
src/lib/utils/cn.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
25
src/proxy.ts
Normal file
25
src/proxy.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
// Optimistic check only (cookie presence) — real authorization happens per-request
|
||||||
|
// against the Winofy API, which is the source of truth. See FRONTEND_GUIDE.md §4.3.
|
||||||
|
const AUTH_REQUIRED_PREFIXES = ["/seller", "/cart", "/checkout", "/orders", "/account", "/notifications"];
|
||||||
|
|
||||||
|
export default function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
|
||||||
|
const needsAuth = AUTH_REQUIRED_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||||
|
|
||||||
|
if (needsAuth && !hasSession) {
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api|_next/static|_next/image|.*\\.(?:png|jpg|jpeg|svg|ico)$).*)"],
|
||||||
|
};
|
||||||
181
src/types/api.ts
Normal file
181
src/types/api.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/** Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman. */
|
||||||
|
|
||||||
|
export interface City {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Neighborhood {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreCategory {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreListItem {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
category: StoreCategory;
|
||||||
|
logo: string | null;
|
||||||
|
cover_image: string | null;
|
||||||
|
/** DRF serializes DecimalField as a string, e.g. "3.76" — confirmed against staging. */
|
||||||
|
rating_avg: string;
|
||||||
|
rating_count: number;
|
||||||
|
min_order_amount: number;
|
||||||
|
delivery_fee: number;
|
||||||
|
free_delivery_threshold: number | null;
|
||||||
|
is_open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface WorkingHours {
|
||||||
|
weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Saturday .. 6 = Friday
|
||||||
|
opens_at: string;
|
||||||
|
closes_at: string;
|
||||||
|
is_closed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreDetail extends StoreListItem {
|
||||||
|
description: string;
|
||||||
|
address: string;
|
||||||
|
phone_number: string;
|
||||||
|
working_hours: WorkingHours[];
|
||||||
|
accepts_wallet: boolean;
|
||||||
|
accepts_online: boolean;
|
||||||
|
accepts_cash_on_delivery: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnitType = "gram" | "ml" | "piece";
|
||||||
|
|
||||||
|
export interface ProductCategory {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
parent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductListItem {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
price: number;
|
||||||
|
unit_type: UnitType;
|
||||||
|
unit_value: number;
|
||||||
|
is_active: boolean;
|
||||||
|
is_out_of_stock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductDetail extends ProductListItem {
|
||||||
|
description: string;
|
||||||
|
category: ProductCategory;
|
||||||
|
store: StoreListItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddressLabel = "home" | "work" | "other";
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
uuid: string;
|
||||||
|
label: AddressLabel;
|
||||||
|
title: string;
|
||||||
|
full_address: string;
|
||||||
|
plaque: string;
|
||||||
|
floor: string;
|
||||||
|
unit: string;
|
||||||
|
recipient_name: string;
|
||||||
|
recipient_phone: string;
|
||||||
|
city: City;
|
||||||
|
neighborhood: Neighborhood | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
uuid: string;
|
||||||
|
product: ProductListItem;
|
||||||
|
quantity: number;
|
||||||
|
line_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartGroup {
|
||||||
|
store: StoreListItem;
|
||||||
|
items: CartItem[];
|
||||||
|
items_subtotal: number;
|
||||||
|
delivery_fee: number;
|
||||||
|
total: number;
|
||||||
|
meets_minimum_order: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cart {
|
||||||
|
groups: CartGroup[];
|
||||||
|
grand_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeliveryType = "express" | "scheduled";
|
||||||
|
export type PaymentMethod = "wallet" | "online" | "cash_on_delivery";
|
||||||
|
export type PaymentStatus = "pending" | "paid" | "failed";
|
||||||
|
export type OrderStatus =
|
||||||
|
| "placed"
|
||||||
|
| "preparing"
|
||||||
|
| "ready_to_ship"
|
||||||
|
| "handed_to_courier"
|
||||||
|
| "delivered"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export interface OrderItem {
|
||||||
|
uuid: string;
|
||||||
|
product: string;
|
||||||
|
product_name_snapshot: string;
|
||||||
|
unit_price_snapshot: number;
|
||||||
|
quantity: number;
|
||||||
|
line_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderStatusLog {
|
||||||
|
from_status: string;
|
||||||
|
to_status: string;
|
||||||
|
note: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
uuid: string;
|
||||||
|
store: StoreListItem;
|
||||||
|
status: OrderStatus;
|
||||||
|
items: OrderItem[];
|
||||||
|
items_subtotal: number;
|
||||||
|
delivery_fee: number;
|
||||||
|
commission_amount: number;
|
||||||
|
seller_payout_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
cancel_reason: string;
|
||||||
|
courier_name: string;
|
||||||
|
courier_phone: string;
|
||||||
|
placed_at: string;
|
||||||
|
delivered_at: string | null;
|
||||||
|
status_logs: OrderStatusLog[];
|
||||||
|
is_cancellable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderGroup {
|
||||||
|
uuid: string;
|
||||||
|
recipient_name: string;
|
||||||
|
recipient_phone: string;
|
||||||
|
full_address: string;
|
||||||
|
delivery_type: DeliveryType;
|
||||||
|
scheduled_at: string | null;
|
||||||
|
payment_method: PaymentMethod;
|
||||||
|
payment_status: PaymentStatus;
|
||||||
|
notes: string;
|
||||||
|
total_amount: number;
|
||||||
|
orders: Order[];
|
||||||
|
created_at: string;
|
||||||
|
/** `feature/payment` only. */
|
||||||
|
payment?: { method: PaymentMethod; status: string; payment_url?: string };
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue