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";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
};
|
||||
|
||||
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"
|
||||
},
|
||||
"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",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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";
|
||||
|
||||
/*
|
||||
* Placeholder tokens — TODO replace with real values pulled from the Figma
|
||||
* design-system page (get_variable_defs) once file access is granted.
|
||||
*/
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-primary: var(--primary);
|
||||
--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) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--secondary: #1f2937;
|
||||
--secondary-foreground: #ededed;
|
||||
--muted: #1f2937;
|
||||
--muted-foreground: #9ca3af;
|
||||
--border: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
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 { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Vazirmatn } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/lib/query-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
const vazirmatn = Vazirmatn({
|
||||
variable: "--font-vazirmatn",
|
||||
subsets: ["arabic"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "وینوفی",
|
||||
description: "خرید آنلاین از فروشگاههای محله شما",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</body>
|
||||
</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