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:
Afra ‌ 2026-08-15 14:56:52 +03:30
parent 4e1b6f8bad
commit 6a44887111
22 changed files with 1860 additions and 95 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

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

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

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

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

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

View file

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

View file

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

View file

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

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

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

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