Merge pull request 'feature/WNPT-733' (#1) from feature/WNPT-733 into main

Reviewed-on: #1
This commit is contained in:
afra.khalaj 2026-08-26 04:17:34 -04:00
commit 17ee08bd5f
49 changed files with 1338 additions and 377 deletions

View file

@ -2,6 +2,16 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// Prints every server-side fetch() (URL, method, cache status) to the
// terminal running `next dev` — the only way to see calls like the home
// page's stores-by-neighborhood fetch, since those run on the server and
// never appear in the browser's Network tab (dev-only; has no effect on
// `next build`/`next start`).
logging: {
fetches: {
fullUrl: true,
},
},
turbopack: {
root: __dirname,
},

BIN
public/satelliet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -1,26 +1,8 @@
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { requireSession } from "@/lib/auth/session";
import type { Address, City, Neighborhood } from "@/types/api";
import { getAddresses } from "@/lib/api/addresses";
import { getCities, getNeighborhoods } from "@/lib/api/locations";
import { AddressList } from "@/components/customer/address-list";
async function getAddresses() {
const res = await winofyFetch<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
return Array.isArray(res) ? res : res.results;
}
async function getCities() {
const res = await winofyFetch<PaginatedResponse<City> | City[]>("/v1/cities/", { auth: false });
return Array.isArray(res) ? res : res.results;
}
async function getNeighborhoods() {
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200 },
});
return Array.isArray(res) ? res : res.results;
}
export default async function AddressesPage() {
await requireSession("/addresses");
const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]);

View file

@ -1,5 +1,5 @@
import Link from "next/link";
import { getCart } from "@/lib/actions/cart";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { CartItemRow } from "@/components/customer/cart-item-row";
import { Button } from "@/components/ui/button";

View file

@ -1,15 +1,9 @@
import { redirect } from "next/navigation";
import { getCart } from "@/lib/actions/cart";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import type { Address } from "@/types/api";
import { getAddresses } from "@/lib/api/addresses";
import { CheckoutForm } from "@/components/customer/checkout-form";
async function getAddresses() {
const res = await winofyFetch<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
return Array.isArray(res) ? res : res.results;
}
export default async function CheckoutPage() {
await requireSession("/checkout");
const [cart, addresses] = await Promise.all([getCart(), getAddresses()]);

View file

@ -1,23 +1,17 @@
import { notFound } from "next/navigation";
import { winofyFetch, ApiError } from "@/lib/api/winofy";
import { ApiError } from "@/lib/api/winofy";
import { requireSession } from "@/lib/auth/session";
import { getOrderGroup } from "@/lib/api/orders";
import { OrderCard } from "@/components/customer/order-card";
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
import type { OrderGroup } from "@/types/api";
async function getOrderGroup(uuid: string) {
try {
return await winofyFetch<OrderGroup>(`/v1/order-groups/${uuid}/`);
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) {
const { uuid } = await params;
await requireSession(`/order-groups/${uuid}`);
const orderGroup = await getOrderGroup(uuid);
const orderGroup = await getOrderGroup(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!orderGroup) notFound();
return (

View file

@ -1,13 +1,7 @@
import Link from "next/link";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { requireSession } from "@/lib/auth/session";
import { getOrderGroups } from "@/lib/api/orders";
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
import type { OrderGroup } from "@/types/api";
async function getOrderGroups() {
const res = await winofyFetch<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
return res.results;
}
export default async function OrdersPage() {
await requireSession("/orders");

View file

@ -1,53 +1,46 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { winofyFetch, ApiError } from "@/lib/api/winofy";
import type { ProductDetail } from "@/types/api";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
const UNIT_LABELS_FA: Record<string, string> = { gram: "گرم", ml: "میلی‌لیتر", piece: "عدد" };
async function getProduct(uuid: string) {
try {
return await winofyFetch<ProductDetail>(`/v1/products/${uuid}/`, { auth: false });
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
import { ApiError } from "@/lib/api/winofy";
import { getProduct } from "@/lib/api/catalog";
import { HeroBackButton } from "@/components/ui/hero-back-button";
import { ProductPurchasePanel } from "@/components/product/product-purchase-panel";
export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) {
const { uuid } = await params;
const product = await getProduct(uuid);
const product = await getProduct(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!product) notFound();
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
<Link href={`/stores/${product.store.uuid}`} className="text-sm text-primary underline underline-offset-4">
← بازگشت به {product.store.name}
</Link>
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
<main className="flex min-h-full flex-1 flex-col bg-white pb-[140px]">
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
)}
<HeroBackButton href={`/stores/${product.store.uuid}`} />
</div>
<div className="flex flex-col gap-2">
<h1 className="text-xl font-bold">{product.name}</h1>
<p className="text-sm text-muted-foreground">
{product.unit_value} {UNIT_LABELS_FA[product.unit_type] ?? product.unit_type}
</p>
<p className="text-lg font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
{product.description && <p className="text-sm text-muted-foreground">{product.description}</p>}
</div>
<div className="flex flex-col gap-5 px-5 pt-5">
<div className="flex flex-col gap-2">
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-semibold leading-none text-primary-action">
{product.store.name}
</Link>
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
</div>
<AddToCartButton
productUuid={product.uuid}
disabled={!product.is_active || product.is_out_of_stock}
outOfStock={product.is_out_of_stock}
className="w-full"
/>
<ProductPurchasePanel product={product}>
<section className="flex flex-col gap-3">
<h2 className="text-[18px] font-bold leading-none text-foreground">توضیحات محصول</h2>
<p className="text-right text-[15px] leading-[1.6] text-gray-600">{product.description}</p>
</section>
</ProductPurchasePanel>
</div>
</main>
);
}

View file

@ -1,135 +1,69 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
import type { ProductListItem, StoreDetail } from "@/types/api";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
import { ApiError } from "@/lib/api/winofy";
import { getStore } from "@/lib/api/stores";
import { getProducts } from "@/lib/api/catalog";
import { getCart } from "@/lib/api/cart";
import { getValidSession } from "@/lib/auth/session";
import { HeroBackButton } from "@/components/ui/hero-back-button";
import { StoreInfoCard } from "@/components/store/store-info-card";
import { StoreSearchBar } from "@/components/store/store-search-bar";
import { ProductGridCard } from "@/components/store/product-grid-card";
import { StickyCartBar } from "@/components/store/sticky-cart-bar";
import type { CartItem } from "@/types/api";
const WEEKDAY_LABELS_FA = ["شنبه", "یک‌شنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه"];
export default async function StoreDetailPage({
params,
searchParams,
}: PageProps<"/stores/[uuid]">) {
const { uuid } = await params;
const { search: searchRaw } = await searchParams;
const search = typeof searchRaw === "string" ? searchRaw : undefined;
async function getStore(uuid: string) {
try {
return await winofyFetch<StoreDetail>(`/v1/stores/${uuid}/`, { auth: false });
} catch (err) {
const store = await getStore(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
async function getStoreProducts(uuid: string) {
const res = await winofyFetch<PaginatedResponse<ProductListItem>>("/v1/products/", {
auth: false,
params: { store: uuid, limit: 100 },
});
return res.results;
}
export default async function StoreDetailPage({ params }: PageProps<"/stores/[uuid]">) {
const { uuid } = await params;
const store = await getStore(uuid);
if (!store) notFound();
const products = await getStoreProducts(uuid);
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
const cart = session ? await getCart() : null;
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
const cartItemByProduct = new Map<string, CartItem>();
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
return (
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">
<div className="overflow-hidden rounded-2xl border border-border">
<div className="h-32 w-full bg-muted">
{store.cover_image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center gap-3">
{store.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={store.logo_url}
alt={store.name}
className="h-16 w-16 shrink-0 rounded-full border border-border object-cover"
/>
) : (
<div className="h-16 w-16 shrink-0 rounded-full bg-muted" />
)}
<div>
<h1 className="text-lg font-bold">{store.name}</h1>
<p className="text-sm text-muted-foreground">{store.category.name}</p>
<p className="text-xs text-muted-foreground">
⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count} نظر) ·{" "}
{store.is_open ? "باز" : "بسته"}
</p>
</div>
</div>
{store.description && <p className="text-sm text-muted-foreground">{store.description}</p>}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-3 py-1">
حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان
</span>
<span className="rounded-full bg-muted px-3 py-1">
هزینه ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان
</span>
{store.free_delivery_threshold != null && (
<span className="rounded-full bg-muted px-3 py-1">
ارسال رایگان بالای {store.free_delivery_threshold.toLocaleString("fa-IR")} تومان
</span>
)}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{store.accepts_wallet && <span className="rounded-full border border-border px-3 py-1">کیف پول</span>}
{store.accepts_online && <span className="rounded-full border border-border px-3 py-1">درگاه بانکی</span>}
{store.accepts_cash_on_delivery && (
<span className="rounded-full border border-border px-3 py-1">پرداخت در محل</span>
)}
</div>
{store.working_hours?.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer font-medium">ساعات کاری</summary>
<ul className="mt-2 flex flex-col gap-1 text-muted-foreground">
{store.working_hours.map((wh) => (
<li key={wh.weekday} className="flex justify-between">
<span>{WEEKDAY_LABELS_FA[wh.weekday]}</span>
<span>{wh.is_closed ? "تعطیل" : `${wh.opens_at} تا ${wh.closes_at}`}</span>
</li>
))}
</ul>
</details>
)}
</div>
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
{store.cover_image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
<HeroBackButton href="/" />
</div>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">محصولات</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{products.map((product) => (
<article key={product.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-3">
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={product.image_url}
alt={product.name}
className="h-full w-full object-cover"
/>
)}
</div>
<h3 className="line-clamp-2 text-sm font-medium">{product.name}</h3>
<p className="text-sm font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
</Link>
<AddToCartButton
productUuid={product.uuid}
disabled={!product.is_active || product.is_out_of_stock}
outOfStock={product.is_out_of_stock}
/>
</article>
))}
{products.length === 0 && <p className="text-sm text-muted-foreground">محصولی یافت نشد.</p>}
</div>
<StoreInfoCard store={store} />
<section className="px-4 pt-2">
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
</section>
<div className="pt-4">
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
</div>
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
{products.map((product) => (
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
))}
{products.length === 0 && (
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
)}
</section>
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
</main>
);
}

View file

@ -2,10 +2,30 @@
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { MapPinOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { setLocation } from "@/lib/location/client";
// Matches the standard GeolocationPositionError codes.
const REASON_MESSAGES: Record<string, { title: string; body: string }> = {
"1": {
title: "دسترسی به موقعیت مکانی رد شد",
body: "برای استفاده از این قابلیت، دسترسی به موقعیت مکانی را برای این سایت از تنظیمات مرورگر یا سیستم‌عامل خود فعال کنید.",
},
"2": {
title: "موقعیت مکانی شناسایی نشد",
body: "سیستم‌عامل شما نتوانست موقعیت فعلی را تشخیص دهد — معمولاً به این دلیل که سرویس موقعیت‌یابی (Location Services) خاموش است یا اتصال Wi-Fi برقرار نیست. لطفاً این موارد را بررسی کنید یا محله خود را دستی انتخاب کنید.",
},
"3": {
title: "زمان شناسایی موقعیت به پایان رسید",
body: "دریافت موقعیت مکانی بیش از حد طول کشید. لطفاً دوباره تلاش کنید یا محله خود را دستی انتخاب کنید.",
},
};
const DEFAULT_REASON = {
title: "موقعیت مکانی شناسایی نشد",
body: "مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.",
};
// L04 — winofyfrontenddoc (1).md §4.2. CTA hierarchy deliberately flips from L01:
// manual selection is primary here (most likely to succeed), retry is secondary.
export default function LocationErrorPage() {
@ -20,8 +40,11 @@ function LocationErrorPageInner() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") ?? "/";
const reason = searchParams.get("reason");
const [retrying, setRetrying] = useState(false);
const { title, body } = (reason ? REASON_MESSAGES[reason] : undefined) ?? DEFAULT_REASON;
function retry() {
setRetrying(true);
navigator.geolocation.getCurrentPosition(
@ -30,23 +53,26 @@ function LocationErrorPageInner() {
router.push(next);
router.refresh();
},
() => setRetrying(false),
(error) => {
setRetrying(false);
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
},
{ timeout: 10000 },
);
}
return (
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-error/10 text-error">
<MapPinOff className="h-14 w-14" />
</span>
<div className="flex flex-col gap-2">
<h1 className="text-headline-medium font-extrabold text-foreground">موقعیت مکانی شناسایی نشد</h1>
<p className="text-body-large text-gray-700">مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/satelliet.png" alt="" className="w-auto h-full my-4" width={390} height={296}/>
<div className="flex flex-col gap-6 my-4">
<h1 className="text-headline-medium font-extrabold text-foreground">{title}</h1>
<p className="text-body-large text-gray-700 leading-6">{body}</p>
</div>
<div className="mt-auto flex w-full flex-col gap-4">
<div className="mt-[50%] flex w-full flex-col gap-8">
<Button
size="lg"
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
@ -57,7 +83,7 @@ function LocationErrorPageInner() {
<button type="button" onClick={retry} disabled={retrying} className="text-body-medium font-semibold text-primary-action">
{retrying ? "در حال تلاش..." : "تلاش مجدد"}
</button>
<span className="text-body-medium text-gray-500">پشتیبانی</span>
<span className="text-body-medium text-primary-action mt-8">پشتیبانی</span>
</div>
</main>
);

View file

@ -12,8 +12,7 @@ const BENEFITS = [
{ icon: Store, text: "کشف بازارچه‌های اطراف" },
];
// L01 — winofyfrontenddoc (1).md §4.1. No illustration asset was provided;
// approximated with a simple icon badge rather than the isometric map graphic.
// L01 — winofyfrontenddoc (1).md §4.1.
export default function LocationPermissionPage() {
return (
<Suspense fallback={null}>
@ -32,55 +31,69 @@ function LocationPermissionPageInner() {
setLoading(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ mode: "geo", lat: position.coords.latitude, lng: position.coords.longitude });
setLocation({
mode: "geo",
lat: position.coords.latitude,
lng: position.coords.longitude,
});
router.push(next);
router.refresh();
},
() => {
(error) => {
setLoading(false);
router.push(`/location/error?next=${encodeURIComponent(next)}`);
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
},
{ timeout: 10000 },
);
}
return (
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-primary-action/10 text-primary-action">
<MapPin className="h-14 w-14" />
</span>
<main className="flex min-h-full flex-1 flex-col bg-surface">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/Rectangle.png" alt="" className="h-auto w-full" width={390} height={296} />
<div className="flex flex-col gap-2">
<h1 className="text-headline-medium font-extrabold text-foreground">بازارچه محلی شما</h1>
<p className="text-body-large text-gray-700">برای نمایش فروشگاه‌های نزدیک، به موقعیت مکانی شما نیاز داریم</p>
</div>
<div className="flex flex-1 flex-col items-center gap-8 px-6 py-10 text-start">
<div className="flex flex-col gap-8">
<h1 className="text-headline-medium font-extrabold text-foreground">
بازارچه محلی شما
</h1>
<p className="text-body-large text-gray-700 leading-6">
برای نمایش فروشگاه‌های نزدیک، به موقعیت مکانی شما نیاز داریم
</p>
</div>
<div className="flex w-full flex-col gap-3 text-right">
{BENEFITS.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3">
<Icon className="h-5 w-5 shrink-0 text-primary-action" />
<span className="text-body-medium text-foreground">{text}</span>
</div>
))}
</div>
<div className="flex w-full flex-col gap-3 text-right">
{BENEFITS.map(({ icon: Icon, text }) => (
<div
key={text}
className="flex items-center gap-3 mb-3 mr-2"
>
<Icon className="h-5 w-5 shrink-0 text-primary-action" />
<span className="text-body-medium text-foreground">{text}</span>
</div>
))}
</div>
<div className="mt-auto flex w-full flex-col gap-4">
<Button
size="lg"
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
disabled={loading}
onClick={allowLocation}
>
{loading ? "در حال دریافت موقعیت..." : "اجازه دسترسی به موقعیت مکانی"}
</Button>
<button
type="button"
onClick={() => router.push(`/location/search?next=${encodeURIComponent(next)}`)}
className="text-body-medium font-semibold text-primary-action"
>
انتخاب دستی محله
</button>
<span className="text-body-medium text-gray-500">پشتیبانی</span>
<div className="flex w-full flex-col gap-6">
<Button
size="lg"
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
disabled={loading}
onClick={allowLocation}
>
{loading ? "در حال دریافت موقعیت..." : "اجازه دسترسی به موقعیت مکانی"}
</Button>
<button
type="button"
onClick={() =>
router.push(`/location/search?next=${encodeURIComponent(next)}`)
}
className="text-body-medium font-semibold text-primary-action"
>
انتخاب دستی محله
</button>
<span className="text-body-medium text-primary-action text-center mt-6">پشتیبانی</span>
</div>
</div>
</main>
);

View file

@ -1,17 +1,8 @@
import { Suspense } from "react";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { getLocationServer } from "@/lib/location/server";
import type { Neighborhood } from "@/types/api";
import { getNeighborhoods } from "@/lib/api/locations";
import { NeighborhoodSearch } from "@/components/location/neighborhood-search";
async function getNeighborhoods() {
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200 },
});
return Array.isArray(res) ? res : res.results;
}
export default async function NeighborhoodSearchPage() {
const [neighborhoods, location] = await Promise.all([getNeighborhoods(), getLocationServer()]);
const selectedNeighborhoodUuid = location?.mode === "manual" ? location.neighborhoodUuid : null;

View file

@ -1,9 +1,8 @@
import { Suspense } from "react";
import { getValidSession } from "@/lib/auth/session";
import { getLocationServer } from "@/lib/location/server";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { getCart } from "@/lib/actions/cart";
import type { StoreCategory, StoreListItem } from "@/types/api";
import { getStoreCategories, getStores } from "@/lib/api/stores";
import { getCart } from "@/lib/api/cart";
import { SiteHeader } from "@/components/home/site-header";
import { SearchBar } from "@/components/home/search-bar";
import { LocationReminderBanner } from "@/components/home/location-reminder-banner";
@ -12,21 +11,6 @@ import { StoreCard } from "@/components/home/store-card";
import { SellerPromoCard } from "@/components/home/seller-promo-card";
import { BottomNav } from "@/components/home/bottom-nav";
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(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) {
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
auth: false,
params: { limit: 20, ...params },
});
return res.results;
}
export default async function HomePage({ searchParams }: PageProps<"/">) {
const params = await searchParams;
const category = typeof params.category === "string" ? params.category : undefined;
@ -62,8 +46,8 @@ export default async function HomePage({ searchParams }: PageProps<"/">) {
</div>
)}
<section className="flex flex-col gap-3 pt-4">
<h2 className="px-4 text-title-large font-bold text-foreground">فروشگاه‌های نزدیک</h2>
<section className="flex flex-col gap-4 pt-4">
<h2 className="px-4 text-2xl font-extrabold text-foreground">فروشگاه‌های نزدیک</h2>
<Suspense fallback={null}>
<CategoryChips categories={categories} />
</Suspense>

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { getNeighborhoods } from "@/lib/api/locations";
/**
* Same-origin proxy so the neighborhood search box can make a real, browser-visible
* fetch. Winofy's API has no CORS headers, so the browser can never call it directly
* — this route does the actual upstream call server-side and returns JSON.
*
* The `search` param is forwarded to Winofy — confirmed as a real, intended filter
* (matches against city name too, e.g. `search=مشهد` should surface that city's
* neighborhoods, not just a neighborhood literally named "مشهد"). As of 2026-08-22
* staging doesn't apply it yet (still returns everything unfiltered), so we also
* filter here ourselves — matching city OR neighborhood name — as a fallback that's
* harmless once staging catches up (backend's already-filtered results all pass our
* filter too) and is what actually makes search work today.
*/
export async function GET(request: NextRequest) {
const search = request.nextUrl.searchParams.get("search")?.trim() ?? "";
const neighborhoods = await getNeighborhoods({ search: search || undefined });
const filtered = search
? neighborhoods.filter((n) => n.name.includes(search) || n.city.name.includes(search))
: neighborhoods;
return NextResponse.json(filtered);
}

View file

@ -6,7 +6,7 @@
* don't invent any rather than guess.
*/
:root {
--background: #f7f2fa; /* پس‌زمینه — app background */
--background: #FFFFFF; /* پس‌زمینه — app background */
--surface: #fffbfe; /* سفید — card/sheet surface */
--foreground: #111827; /* gray-900 */
@ -24,8 +24,16 @@
--teal: #2e807a;
--teal-light: #e0f2f1;
--teal-bg: #e6f0f0;
--teal-border: #a7f3d0;
--blue-bg: #ecf2f8;
/* designspec.md (C05/C06) §1.2 — exact hex, not Tailwind's OKLCH-based emerald/slate defaults */
--page-bg: #f8fafc;
--emerald-bg: #ecfdf5;
--emerald-border: #a7f3d0;
--emerald-text-strong: #065f46;
--emerald-500: #10b981;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-300: #e5e7eb;
@ -60,8 +68,15 @@
--color-teal: var(--teal);
--color-teal-light: var(--teal-light);
--color-teal-bg: var(--teal-bg);
--color-teal-border: var(--teal-border);
--color-blue-bg: var(--blue-bg);
--color-page-bg: var(--page-bg);
--color-emerald-bg: var(--emerald-bg);
--color-emerald-border: var(--emerald-border);
--color-emerald-text-strong: var(--emerald-text-strong);
--color-emerald-500: var(--emerald-500);
--color-gray-50: var(--gray-50);
--color-gray-100: var(--gray-100);
--color-gray-300: var(--gray-300);

View file

@ -2,7 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { addToCart } from "@/lib/actions/cart";
import { addToCart } from "@/lib/api/cart";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";

View file

@ -2,7 +2,7 @@
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createAddress, deleteAddress, type AddressInput } from "@/lib/actions/addresses";
import { createAddress, deleteAddress, type AddressInput } from "@/lib/api/addresses";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Address, AddressLabel, City, Neighborhood } from "@/types/api";

View file

@ -2,7 +2,7 @@
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart";
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem } from "@/types/api";
export function CartItemRow({ item }: { item: CartItem }) {

View file

@ -3,7 +3,7 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { submitCheckout } from "@/lib/actions/checkout";
import { submitCheckout } from "@/lib/api/checkout";
import { Button } from "@/components/ui/button";
import type { Address, Cart, DeliveryType, PaymentMethod } from "@/types/api";

View file

@ -2,7 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { cancelOrder } from "@/lib/actions/orders";
import { cancelOrder } from "@/lib/api/orders";
import { Button } from "@/components/ui/button";
import { ORDER_STATUS_LABELS_FA, ORDER_STATUS_SEQUENCE } from "@/lib/order-status";
import type { Order } from "@/types/api";

View file

@ -18,13 +18,15 @@ export function CategoryChips({ categories }: { categories: StoreCategory[] }) {
}
return (
<div className="flex gap-2 overflow-x-auto px-4 pb-1">
<div className="flex gap-2 overflow-x-auto px-4 min-h-full my-3">
<button
type="button"
onClick={() => select(null)}
className={cn(
"shrink-0 rounded-full px-3 py-2 text-body-medium",
!activeUuid ? "bg-primary-action text-white" : "border border-gray-300 bg-white text-foreground",
"shrink-0 rounded-full px-4 py-2 mb-4 text-body-medium font-medium flex items-center",
!activeUuid
? "bg-primary-action text-white"
: "border border-gray-300 bg-white text-foreground",
)}
>
همه
@ -35,7 +37,7 @@ export function CategoryChips({ categories }: { categories: StoreCategory[] }) {
type="button"
onClick={() => select(category.uuid)}
className={cn(
"shrink-0 rounded-full px-3 py-2 text-body-medium",
"shrink-0 rounded-full px-3 py-2 text-body-medium font-medium flex items-center mb-4",
activeUuid === category.uuid
? "bg-primary-action text-white"
: "border border-gray-300 bg-white text-foreground",

View file

@ -1,26 +1,21 @@
import Link from "next/link";
import type { StoreListItem } from "@/types/api";
/** winofyfrontenddoc (1).md §3.5 store-card. */
export function StoreCard({ store }: { store: StoreListItem }) {
return (
<Link
href={`/stores/${store.uuid}`}
className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3 shadow-sm"
className="flex items-center gap-4 rounded-2xl border border-gray-300 bg-white p-4 shadow-sm"
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-[10px] border border-gray-300 bg-muted">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-muted">
{store.logo_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={store.logo_url} alt={store.name} className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<h3 className="text-[15px] leading-[22px] font-bold text-foreground">{store.name}</h3>
<span className="w-fit rounded-full bg-muted px-2 py-0.5 text-label text-gray-600">{store.category.name}</span>
<div className="flex items-center justify-between text-label text-gray-600">
<span>⭐ {Number(store.rating_avg).toFixed(1)}</span>
<span>{store.is_open ? "باز" : "بسته"}</span>
</div>
<div className="flex flex-1 flex-col gap-2">
<h3 className="text-[18px] font-bold leading-6 text-foreground">{store.name}</h3>
<span className="w-fit rounded-full bg-gray-100 px-3 py-1 text-label text-gray-600">{store.category.name}</span>
</div>
</Link>
);

View file

@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, Check, MapPin, Search, SearchX, X } from "lucide-react";
import { setLocation, clearLocation } from "@/lib/location/client";
@ -21,14 +21,46 @@ export function NeighborhoodSearch({
const [query, setQuery] = useState("");
const [focused, setFocused] = useState(false);
const [results, setResults] = useState<Neighborhood[]>([]);
const [searching, setSearching] = useState(false);
const results = useMemo(() => {
if (!query.trim()) return [];
const q = query.trim();
return neighborhoods.filter((n) => n.name.includes(q));
}, [neighborhoods, query]);
// Real, visible fetch to our own /api/locations/neighborhoods route (debounced)
// — not just filtering the initial list in memory.
useEffect(() => {
const trimmed = query.trim();
// Nothing to reset here — the render below only reads `results`/`searching`
// once `query.trim()` is non-empty, so an empty query needs no state change.
if (!trimmed) return;
const state: "idle" | "results" | "empty" = !query.trim() ? "idle" : results.length > 0 ? "results" : "empty";
const controller = new AbortController();
const timeout = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(`/api/locations/neighborhoods?search=${encodeURIComponent(trimmed)}`, {
signal: controller.signal,
});
const data: Neighborhood[] = await res.json();
setResults(data);
} catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) setResults([]);
} finally {
setSearching(false);
}
}, 300);
return () => {
clearTimeout(timeout);
controller.abort();
};
}, [query]);
const state: "idle" | "searching" | "results" | "empty" = !query.trim()
? "idle"
: searching
? "searching"
: results.length > 0
? "results"
: "empty";
const suggestions = neighborhoods.slice(0, 8);
function selectNeighborhood(n: Neighborhood) {
@ -55,9 +87,9 @@ export function NeighborhoodSearch({
<button
type="button"
onClick={() => selectNeighborhood(n)}
className="flex w-full items-center gap-3 border-b border-gray-300 py-4 text-right"
className="flex max-w-full items-center gap-3 border-t border-gray-300 py-4 text-right mx-4"
>
<MapPin className={isSelected ? "h-5 w-5 shrink-0 text-primary-action" : "h-5 w-5 shrink-0 text-gray-500"} />
<MapPin className={isSelected ? "h-5 w-5 shrink-0 text-primary-action" : "h-5 w-5 shrink-0 text-gray-700"} />
<span className={isSelected ? "flex-1 text-body-large font-semibold text-primary-action" : "flex-1 text-body-large text-foreground"}>
{n.city.name}، {n.name}
</span>
@ -67,12 +99,12 @@ export function NeighborhoodSearch({
}
return (
<main className="flex min-h-full flex-1 flex-col bg-surface px-4 py-4">
<div className="mb-4 flex items-center gap-3">
<main className="flex min-h-full flex-1 flex-col bg-surface px-4 py-12">
<div className="mb-8 flex items-center gap-3">
<button type="button" onClick={() => router.back()} aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</button>
<h1 className="text-title-large font-extrabold text-foreground">انتخاب محله</h1>
<h1 className="text-title-large font-extrabold text-foreground mr-24">انتخاب محله</h1>
</div>
<div
@ -82,15 +114,15 @@ export function NeighborhoodSearch({
: "flex h-12 items-center gap-2 rounded-3xl border border-gray-300 bg-white px-4"
}
>
<Search className="h-5 w-5 shrink-0 text-gray-500" />
<Search className="h-5 w-5 shrink-0 text-primary-action" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="با نام محله خود را جستجو کنید"
className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-500 focus:outline-none"
placeholder="شهر یا محله خود را جستجو کنید"
className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-600 focus:outline-none"
autoFocus
/>
</div>
@ -106,17 +138,21 @@ export function NeighborhoodSearch({
</button>
)}
{state === "searching" && (
<p className="py-8 text-center text-body-medium text-gray-500">در حال جستجو...</p>
)}
{state === "empty" && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<div className="flex flex-col items-center gap-4 py-6 text-center">
<SearchX className="h-12 w-12 text-gray-400" />
<h2 className="text-title-medium font-semibold text-foreground">محله‌ای با این نام پیدا نشد</h2>
<p className="text-body-medium text-gray-600">نام محله را بررسی کنید یا محله دیگری را جستجو کنید</p>
<p className="text-body-medium text-gray-600">نام محله را بررسی کنید یا محله دیگری جستجو کنید</p>
</div>
)}
{state === "results" && (
<div className="mt-4 flex flex-col">
<h2 className="pb-2 text-label text-gray-600">نتایج جستجو</h2>
<div className="m-4 flex flex-col">
<h2 className="pb-4 text-label text-gray-600">نتایج جستجو</h2>
{results.map((n) => (
<NeighborhoodRow key={n.uuid} n={n} />
))}
@ -125,7 +161,7 @@ export function NeighborhoodSearch({
{(state === "idle" || state === "empty") && (
<div className="mt-4 flex flex-col">
<h2 className="pb-2 text-label text-gray-600">محله‌های پیشنهادی</h2>
<h2 className="text-label min-w-fit font-bold size-3.5 text-gray-700 mx-4 py-6 flex items-center">محله‌های پیشنهادی</h2>
{suggestions.map((n) => (
<NeighborhoodRow key={n.uuid} n={n} />
))}

View file

@ -0,0 +1,108 @@
"use client";
import { useEffect, useState, useTransition, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus, X, CheckCircle2 } from "lucide-react";
import { addToCart } from "@/lib/api/cart";
import { toPersianDigits } from "@/lib/format/persian-digits";
import type { ProductDetail } from "@/types/api";
/** designspec.md §3.2.4/§3.4/§3.5 — quantity stepper, success toast, sticky add-to-cart bar. */
export function ProductPurchasePanel({ product, children }: { product: ProductDetail; children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [quantity, setQuantity] = useState(1);
const [showToast, setShowToast] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const disabled = !product.is_active || product.is_out_of_stock;
useEffect(() => {
if (!showToast) return;
const timeout = setTimeout(() => setShowToast(false), 3000);
return () => clearTimeout(timeout);
}, [showToast]);
function handleAddToCart() {
setError(null);
startTransition(async () => {
const result = await addToCart(product.uuid, quantity);
if (result.ok) {
setShowToast(true);
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
return (
<>
<div className="flex h-10 items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setQuantity((q) => q + 1)}
aria-label="افزایش تعداد"
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
aria-label="کاهش تعداد"
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
>
<Minus className="h-4 w-4" />
</button>
</div>
<span className={`text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}>
{product.is_out_of_stock ? "ناموجود" : "موجود ●"}
</span>
</div>
<div className="h-px w-full bg-gray-300" />
{children}
{error && <p className="px-5 text-body-medium text-error">{error}</p>}
{showToast && (
<div className="fixed inset-x-0 bottom-[135px] z-20 mx-auto flex w-[358px] max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
<X className="h-4 w-4 text-emerald-500" />
</button>
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
محصول به سبد خرید اضافه شد
</span>
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
</div>
)}
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="flex w-full items-center justify-between gap-4">
<button
type="button"
onClick={handleAddToCart}
disabled={disabled || isPending}
className="flex h-[53px] w-[245px] items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
>
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
<div className="flex flex-col items-end gap-0.5">
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
<span className="text-[18px] font-bold leading-none text-foreground">
{(product.price * quantity).toLocaleString("fa-IR")} تومان
</span>
</div>
</div>
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
</div>
</>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus } from "lucide-react";
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem, ProductListItem } from "@/types/api";
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [localItem, setLocalItem] = useState(cartItem);
const [error, setError] = useState<string | null>(null);
const quantity = localItem?.quantity ?? 0;
const disabled = !product.is_active || product.is_out_of_stock;
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function handleAdd() {
startTransition(async () => {
const result = await addToCart(product.uuid, 1);
if (result.ok) {
setLocalItem(result.data);
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
function handleChange(nextQuantity: number) {
if (!localItem) return;
startTransition(async () => {
if (nextQuantity <= 0) {
const result = await removeCartItem(localItem.uuid);
// Only clear locally once the backend actually confirms removal — otherwise
// the card would show "add" again while the item still exists server-side.
if (result.ok) setLocalItem(null);
else setError(result.message);
} else {
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
if (result.ok) setLocalItem(result.data);
else setError(result.message);
}
router.refresh();
});
}
return (
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
{error && (
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
{error}
</div>
)}
<Link href={`/products/${product.uuid}`} className="block shrink-0">
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
)}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
</div>
</Link>
<div className="flex flex-1 flex-col justify-between p-3">
<div className="flex flex-col gap-1.5">
<Link href={`/products/${product.uuid}`}>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
</Link>
<p className="text-right text-body-medium font-extrabold text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
</div>
{disabled ? (
<span className="text-center text-label text-gray-500">ناموجود</span>
) : quantity === 0 ? (
<button
type="button"
onClick={handleAdd}
disabled={isPending}
aria-label="افزودن به سبد"
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
) : (
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
<button
type="button"
onClick={() => handleChange(quantity + 1)}
disabled={isPending}
aria-label="افزایش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
<button
type="button"
onClick={() => handleChange(quantity - 1)}
disabled={isPending}
aria-label="کاهش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</button>
</div>
)}
</div>
</article>
);
}

View file

@ -0,0 +1,32 @@
import Link from "next/link";
import { ShoppingBag } from "lucide-react";
/** designspec.md §2.6 — only rendered when this store's cart group actually has items. */
export function StickyCartBar({ itemCount, total }: { itemCount: number; total: number }) {
return (
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="flex w-full items-center justify-between">
<Link
href="/cart"
className="flex h-[50px] items-center gap-2 rounded-full bg-primary-action px-6 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
>
<span className="relative flex h-[18px] w-[18px] items-center justify-center">
<ShoppingBag className="h-[18px] w-[18px]" />
<span className="absolute -top-1.5 -left-1.5 flex h-[14px] min-w-[14px] items-center justify-center rounded-full bg-white text-[10px] font-bold text-primary-action">
{itemCount.toLocaleString("fa-IR")}
</span>
</span>
<span className="text-body-large font-bold">مشاهده سبد</span>
</Link>
<div className="flex flex-col items-end gap-0.5">
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
<span className="text-[18px] font-bold leading-none text-foreground">
{total.toLocaleString("fa-IR")} تومان
</span>
</div>
</div>
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
</div>
);
}

View file

@ -0,0 +1,71 @@
import { MapPin, Phone } from "lucide-react";
import { toPersianDigits } from "@/lib/format/persian-digits";
import type { StoreDetail } from "@/types/api";
/** Iranian week: 0=Saturday..6=Friday. JS Date#getDay(): 0=Sunday..6=Saturday. */
function todaysWorkingHours(store: StoreDetail) {
const jsDay = new Date().getDay();
const iranianWeekday = (jsDay + 1) % 7;
return store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null;
}
function formatTime(value: string | null) {
if (!value) return "";
return toPersianDigits(value.slice(0, 5));
}
/** designspec.md §2.2 — hero-overlapping white info card. */
export function StoreInfoCard({ store }: { store: StoreDetail }) {
const today = todaysWorkingHours(store);
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
return (
<div className="relative z-10 -mt-6 rounded-t-3xl bg-white px-4 pt-5 pb-4">
<h1 className="text-title-large font-extrabold leading-none text-primary-action">{store.name}</h1>
<p className="mt-1 text-[14px] leading-none text-gray-700">{store.category.name}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{today && (
<span className="rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
{today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
</span>
)}
<span className="text-[13px] leading-none text-gray-600">|</span>
<span
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
store.is_open ? "border-emerald-border bg-emerald-bg text-emerald-text-strong" : "border-gray-300 bg-gray-100 text-gray-600"
}`}
>
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`} />
{store.is_open ? "فعال" : "بسته"}
</span>
</span>
</div>
<div className="mt-4 flex items-center gap-2 rounded-[10px] border border-teal-border p-2">
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
<MapPin className="h-4 w-4" />
</span>
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">{store.address}</span>
<a href={mapsUrl} target="_blank" rel="noreferrer" className="shrink-0 text-[13px] font-medium text-teal">
مسیریابی
</a>
</div>
{store.phone_number && (
<a
href={`tel:${store.phone_number}`}
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
>
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
<Phone className="h-4 w-4" />
</span>
<span dir="ltr" className="text-right text-[14px] text-foreground">
{store.phone_number}
</span>
</a>
)}
</div>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
/** designspec.md §2.4 — filters this store's product grid via ?search=. */
export function StoreSearchBar({ storeUuid, defaultValue = "" }: { storeUuid: string; defaultValue?: string }) {
const router = useRouter();
const [value, setValue] = useState(defaultValue);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (value.trim()) params.set("search", value.trim());
router.push(params.toString() ? `/stores/${storeUuid}?${params.toString()}` : `/stores/${storeUuid}`);
}
return (
<form onSubmit={handleSubmit} className="px-4">
<div className="flex h-12 items-center gap-2.5 rounded-3xl border border-gray-300 bg-white px-4 shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039)]">
<Search className="h-4 w-4 shrink-0 text-gray-500" />
<input
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="جستجو محصول"
className="h-full flex-1 bg-transparent text-right text-[14px] text-foreground placeholder:text-gray-400 focus:outline-none"
/>
</div>
</form>
);
}

View file

@ -0,0 +1,15 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
/** designspec.md §2.1/§3.1 — identical glass circle on both C05 and C06 hero images. */
export function HeroBackButton({ href }: { href: string }) {
return (
<Link
href={href}
aria-label="بازگشت"
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-md"
>
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
</Link>
);
}

View file

@ -1,17 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch } from "@/lib/api/winofy";
import { callApi, type ActionResult } from "./result";
import type { Order } from "@/types/api";
export async function cancelOrder(uuid: string, reason: string = ""): Promise<ActionResult<Order>> {
const result = await callApi(() =>
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { reason } }),
);
if (result.ok) {
revalidatePath(`/orders/${uuid}`);
revalidatePath("/orders");
}
return result;
}

View file

@ -1,10 +1,15 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch } from "@/lib/api/winofy";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Address, AddressLabel } from "@/types/api";
export async function getAddresses(): Promise<Address[]> {
const res = await winofyFetch<PaginatedResponse<Address>>("/v1/addresses/");
return res.results;
}
export interface AddressInput {
label: AddressLabel;
title: string;
@ -22,9 +27,7 @@ export interface AddressInput {
}
export async function createAddress(input: AddressInput): Promise<ActionResult<Address>> {
const result = await callApi(() =>
winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }),
);
const result = await callApi(() => winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }));
if (result.ok) revalidatePath("/addresses");
return result;
}

View file

@ -1,27 +1,25 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch } from "@/lib/api/winofy";
import { winofyFetch } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Cart } from "@/types/api";
import type { Cart, CartItem } from "@/types/api";
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult> {
export async function getCart(): Promise<Cart> {
return winofyFetch<Cart>("/v1/cart/");
}
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult<CartItem>> {
const result = await callApi(() =>
winofyFetch<undefined>("/v1/cart/items/", {
method: "POST",
body: { product_uuid: productUuid, quantity },
}),
winofyFetch<CartItem>("/v1/cart/items/", { method: "POST", body: { product_uuid: productUuid, quantity } }),
);
if (result.ok) revalidatePath("/cart");
return result;
}
export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise<ActionResult> {
export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise<ActionResult<CartItem>> {
const result = await callApi(() =>
winofyFetch<undefined>(`/v1/cart/items/${itemUuid}/`, {
method: "PATCH",
body: { quantity },
}),
winofyFetch<CartItem>(`/v1/cart/items/${itemUuid}/`, { method: "PATCH", body: { quantity } }),
);
if (result.ok) revalidatePath("/cart");
return result;
@ -38,7 +36,3 @@ export async function clearCart(): Promise<ActionResult> {
if (result.ok) revalidatePath("/cart");
return result;
}
export async function getCart(): Promise<Cart> {
return winofyFetch<Cart>("/v1/cart/");
}

30
src/lib/api/catalog.ts Normal file
View file

@ -0,0 +1,30 @@
import "server-only";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import type { ProductCategory, ProductDetail, ProductListItem } from "@/types/api";
export async function getProductCategories(): Promise<ProductCategory[]> {
const res = await winofyFetch<PaginatedResponse<ProductCategory>>("/v1/product-categories/", {
auth: false,
params: { limit: 100 },
});
return res.results;
}
export interface GetProductsParams {
store?: string;
category?: string;
search?: string;
limit?: number;
}
export async function getProducts(params: GetProductsParams = {}): Promise<ProductListItem[]> {
const res = await winofyFetch<PaginatedResponse<ProductListItem>>("/v1/products/", {
auth: false,
params: { limit: 100, ...params },
});
return res.results;
}
export async function getProduct(uuid: string): Promise<ProductDetail> {
return winofyFetch<ProductDetail>(`/v1/products/${uuid}/`, { auth: false });
}

View file

@ -1,6 +1,6 @@
"use server";
import { winofyFetch } from "@/lib/api/winofy";
import { winofyFetch } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api";

62
src/lib/api/dev-logger.ts Normal file
View file

@ -0,0 +1,62 @@
import "server-only";
const DEBUG = process.env.NODE_ENV !== "production";
const MAX_ARRAY_ITEMS = 3;
const RESET = "\x1b[0m";
const CYAN = "\x1b[36m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const RED = "\x1b[31m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
function statusColor(status: number): string {
if (status >= 500) return RED;
if (status >= 400) return YELLOW;
return GREEN;
}
/**
* Keeps big paginated lists (e.g. a 19-store `results` array) from flooding the
* terminal — shows the first few items of any array plus a count, recursively.
*/
function truncateForLog(value: unknown): unknown {
if (Array.isArray(value)) {
const shown = value.slice(0, MAX_ARRAY_ITEMS).map(truncateForLog);
if (value.length > MAX_ARRAY_ITEMS) shown.push(`… ${value.length - MAX_ARRAY_ITEMS} more item(s)`);
return shown;
}
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, truncateForLog(v)]));
}
return value;
}
function prettyJson(value: unknown): string {
return JSON.stringify(truncateForLog(value), null, 2);
}
function indent(text: string): string {
return text
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
}
/**
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
* tab for server-side calls, since they run on the Next.js server and the browser
* never sees them (no CORS on the Winofy API). Never runs in production.
*/
export function logWinofyRequest(method: string, url: string, body: unknown) {
if (!DEBUG) return;
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
}
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
if (!DEBUG) return;
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
}

36
src/lib/api/locations.ts Normal file
View file

@ -0,0 +1,36 @@
import "server-only";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import type { City, Neighborhood } from "@/types/api";
export async function getCities(): Promise<City[]> {
const res = await winofyFetch<PaginatedResponse<City>>("/v1/cities/", { auth: false, params: { limit: 100 } });
return res.results;
}
export async function getCity(uuid: string): Promise<City> {
return winofyFetch<City>(`/v1/cities/${uuid}/`, { auth: false });
}
export interface GetNeighborhoodsParams {
city?: string;
/**
* Forwarded to the API for forward-compatibility, but confirmed against
* staging (2026-08-22) to currently have no effect — the endpoint returns
* the same unfiltered results with or without it. Callers must still filter
* client-side (see NeighborhoodSearch) until the backend actually supports it.
*/
search?: string;
limit?: number;
}
export async function getNeighborhoods(params: GetNeighborhoodsParams = {}): Promise<Neighborhood[]> {
const res = await winofyFetch<PaginatedResponse<Neighborhood>>("/v1/neighborhoods/", {
auth: false,
params: { limit: 200, ...params },
});
return res.results;
}
export async function getNeighborhood(uuid: string): Promise<Neighborhood> {
return winofyFetch<Neighborhood>(`/v1/neighborhoods/${uuid}/`, { auth: false });
}

22
src/lib/api/media.ts Normal file
View file

@ -0,0 +1,22 @@
"use server";
import { winofyFetch } from "./winofy";
import { callApi, type ActionResult } from "./result";
export interface MediaPresignResult {
upload_url: string;
object_key: string;
}
/**
* Step 1 of the upload flow (§2 of the API guide): get a presigned MinIO PUT
* URL. Step 2 — PUT the raw file bytes to `upload_url` directly (client-side,
* no auth header, this is a MinIO URL not a Winofy one) — happens in the
* browser, not here. Step 3 — send the returned `object_key` as the value of
* whatever field you're setting (Store.logo, Product.image, etc.).
*/
export async function presignUpload(filename: string): Promise<ActionResult<MediaPresignResult>> {
return callApi(() =>
winofyFetch<MediaPresignResult>("/media/presign/", { method: "POST", body: { filename } }),
);
}

View file

@ -0,0 +1,31 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Notification } from "@/types/api";
export async function getNotifications(): Promise<Notification[]> {
const res = await winofyFetch<PaginatedResponse<Notification>>("/v1/notifications/");
return res.results;
}
export async function getNotification(uuid: string): Promise<Notification> {
return winofyFetch<Notification>(`/v1/notifications/${uuid}/`);
}
export async function markNotificationRead(uuid: string): Promise<ActionResult<Notification>> {
const result = await callApi(() =>
winofyFetch<Notification>(`/v1/notifications/${uuid}/mark-read/`, { method: "POST", body: {} }),
);
if (result.ok) revalidatePath("/notifications");
return result;
}
export async function markAllNotificationsRead(): Promise<ActionResult> {
const result = await callApi(() =>
winofyFetch<undefined>("/v1/notifications/mark-all-read/", { method: "POST", body: {} }),
);
if (result.ok) revalidatePath("/notifications");
return result;
}

28
src/lib/api/orders.ts Normal file
View file

@ -0,0 +1,28 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Order, OrderGroup } from "@/types/api";
export async function getOrderGroups(): Promise<OrderGroup[]> {
const res = await winofyFetch<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
return res.results;
}
export async function getOrderGroup(uuid: string): Promise<OrderGroup> {
return winofyFetch<OrderGroup>(`/v1/order-groups/${uuid}/`);
}
/** Single-order tracking — one store's slice of an OrderGroup. */
export async function getOrder(uuid: string): Promise<Order> {
return winofyFetch<Order>(`/v1/orders/${uuid}/`);
}
export async function cancelOrder(uuid: string, cancelReason: string = ""): Promise<ActionResult<Order>> {
const result = await callApi(() =>
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { cancel_reason: cancelReason } }),
);
if (result.ok) revalidatePath("/orders");
return result;
}

View file

@ -1,10 +1,11 @@
import "server-only";
import { ApiError } from "@/lib/api/winofy";
import { ApiError } from "./winofy";
export type ActionResult<T = undefined> =
| { ok: true; data: T }
| { ok: false; status: number; message: string; fieldErrors: Record<string, string[]> | null };
/** Wraps a winofyFetch call for use in a Server Action — never throws, always returns a typed result. */
export async function callApi<T>(fn: () => Promise<T>): Promise<ActionResult<T>> {
try {
const data = await fn();

23
src/lib/api/reviews.ts Normal file
View file

@ -0,0 +1,23 @@
"use server";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import { callApi, type ActionResult } from "./result";
import type { Review } from "@/types/api";
export async function getReviews(params: { store?: string; limit?: number } = {}): Promise<Review[]> {
const res = await winofyFetch<PaginatedResponse<Review>>("/v1/reviews/", {
auth: false,
params: { limit: 50, ...params },
});
return res.results;
}
export interface CreateReviewInput {
order_uuid: string;
rating: number;
comment: string;
}
export async function createReview(input: CreateReviewInput): Promise<ActionResult<Review>> {
return callApi(() => winofyFetch<Review>("/v1/reviews/", { method: "POST", body: input }));
}

View file

@ -0,0 +1,8 @@
import "server-only";
import { winofyFetch } from "../winofy";
import type { SellerAnalytics } from "@/types/api";
/** No date-range params are documented — the API returns one fixed reporting window. */
export async function getSellerAnalytics(): Promise<SellerAnalytics> {
return winofyFetch<SellerAnalytics>("/v1/seller/analytics/");
}

View file

@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { Order, OrderStatus } from "@/types/api";
export async function getSellerOrders(status?: OrderStatus): Promise<Order[]> {
const res = await winofyFetch<PaginatedResponse<Order>>("/v1/seller/orders/", {
params: { limit: 100, status },
});
return res.results;
}
export async function getSellerOrder(uuid: string): Promise<Order> {
return winofyFetch<Order>(`/v1/seller/orders/${uuid}/`);
}
async function sellerOrderAction(
uuid: string,
action: string,
body: Record<string, unknown> = {},
): Promise<ActionResult<Order>> {
const result = await callApi(() =>
winofyFetch<Order>(`/v1/seller/orders/${uuid}/${action}/`, { method: "POST", body }),
);
if (result.ok) {
revalidatePath("/seller/orders");
revalidatePath(`/seller/orders/${uuid}`);
}
return result;
}
export async function cancelSellerOrder(uuid: string, cancelReason: string = ""): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "cancel", { cancel_reason: cancelReason });
}
/** placed → preparing */
export async function confirmSellerOrder(uuid: string): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "confirm");
}
/** preparing → ready_to_ship */
export async function markSellerOrderReady(uuid: string): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "mark-ready");
}
/** ready_to_ship → handed_to_courier */
export async function markSellerOrderShipped(
uuid: string,
courierName: string,
courierPhone: string,
): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "mark-shipped", { courier_name: courierName, courier_phone: courierPhone });
}
/** handed_to_courier → delivered */
export async function markSellerOrderDelivered(uuid: string): Promise<ActionResult<Order>> {
return sellerOrderAction(uuid, "mark-delivered");
}

View file

@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { SellerProduct } from "@/types/api";
export async function getSellerProducts(): Promise<SellerProduct[]> {
const res = await winofyFetch<PaginatedResponse<SellerProduct>>("/v1/seller/products/", { params: { limit: 100 } });
return res.results;
}
export async function getSellerProduct(uuid: string): Promise<SellerProduct> {
return winofyFetch<SellerProduct>(`/v1/seller/products/${uuid}/`);
}
export interface SellerProductInput {
name: string;
description: string;
image?: string | null;
category_uuid: string;
price: number;
unit_type: SellerProduct["unit_type"];
unit_value: number;
stock_quantity: number;
low_stock_threshold: number;
is_active: boolean;
}
export async function createSellerProduct(input: SellerProductInput): Promise<ActionResult<SellerProduct>> {
const result = await callApi(() =>
winofyFetch<SellerProduct>("/v1/seller/products/", { method: "POST", body: input }),
);
if (result.ok) revalidatePath("/seller/products");
return result;
}
export async function updateSellerProduct(
uuid: string,
input: Partial<SellerProductInput>,
): Promise<ActionResult<SellerProduct>> {
const result = await callApi(() =>
winofyFetch<SellerProduct>(`/v1/seller/products/${uuid}/`, { method: "PATCH", body: input }),
);
if (result.ok) revalidatePath("/seller/products");
return result;
}
export async function deleteSellerProduct(uuid: string): Promise<ActionResult> {
const result = await callApi(() => winofyFetch<undefined>(`/v1/seller/products/${uuid}/`, { method: "DELETE" }));
if (result.ok) revalidatePath("/seller/products");
return result;
}
/** Focused inventory-only update — returns 204, no body, unlike the general PATCH above. */
export async function updateSellerProductStock(
uuid: string,
input: { stock_quantity: number; low_stock_threshold?: number },
): Promise<ActionResult> {
const result = await callApi(() =>
winofyFetch<undefined>(`/v1/seller/products/${uuid}/stock/`, { method: "PATCH", body: input }),
);
if (result.ok) revalidatePath("/seller/products");
return result;
}

View file

@ -0,0 +1,26 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, type PaginatedResponse } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { Review } from "@/types/api";
export async function getSellerReviews(): Promise<Review[]> {
const res = await winofyFetch<PaginatedResponse<Review>>("/v1/seller/reviews/", { params: { limit: 100 } });
return res.results;
}
/**
* ⚠️ The spec documents this action's request body as the full customer-facing
* review-write shape (order_uuid/rating/comment), which doesn't make sense for
* a reply. Sending `seller_reply` here as the best guess (matches the response
* field name) — confirm the real field name with backend (or from a 400's
* field-error keys) before relying on this.
*/
export async function replySellerReview(uuid: string, replyText: string): Promise<ActionResult<Review>> {
const result = await callApi(() =>
winofyFetch<Review>(`/v1/seller/reviews/${uuid}/reply/`, { method: "POST", body: { seller_reply: replyText } }),
);
if (result.ok) revalidatePath("/seller/reviews");
return result;
}

View file

@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { winofyFetch, ApiError } from "../winofy";
import { callApi, type ActionResult } from "../result";
import type { SellerStore, WorkingHours } from "@/types/api";
/** `null` means the seller hasn't created a store yet (404) — not an error state to surface. */
export async function getSellerStore(): Promise<SellerStore | null> {
try {
return await winofyFetch<SellerStore>("/v1/seller/store/");
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export interface SellerStoreInput {
name: string;
category_uuid: string;
description: string;
logo?: string | null;
cover_image?: string | null;
phone_number: string;
city_uuid: string;
address: string;
latitude: number;
longitude: number;
service_neighborhood_uuids: string[];
delivery_radius_km: string;
min_order_amount: number;
delivery_fee: number;
free_delivery_threshold?: number | null;
accepts_wallet: boolean;
accepts_online: boolean;
accepts_cash_on_delivery: boolean;
is_open: boolean;
}
export async function createSellerStore(input: SellerStoreInput): Promise<ActionResult<SellerStore>> {
const result = await callApi(() => winofyFetch<SellerStore>("/v1/seller/store/", { method: "POST", body: input }));
if (result.ok) revalidatePath("/seller");
return result;
}
export async function updateSellerStore(input: Partial<SellerStoreInput>): Promise<ActionResult<SellerStore>> {
const result = await callApi(() =>
winofyFetch<SellerStore>("/v1/seller/store/", { method: "PATCH", body: input }),
);
if (result.ok) revalidatePath("/seller");
return result;
}
export async function getSellerWorkingHours(): Promise<WorkingHours[]> {
return winofyFetch<WorkingHours[]>("/v1/seller/store/working-hours/");
}
/** Full replace — send all 7 days (weekday 0–6) in one call. */
export async function setSellerWorkingHours(hours: WorkingHours[]): Promise<ActionResult<WorkingHours[]>> {
const result = await callApi(() =>
winofyFetch<WorkingHours[]>("/v1/seller/store/working-hours/", { method: "PUT", body: hours }),
);
if (result.ok) revalidatePath("/seller");
return result;
}

32
src/lib/api/stores.ts Normal file
View file

@ -0,0 +1,32 @@
import "server-only";
import { winofyFetch, type PaginatedResponse } from "./winofy";
import type { StoreCategory, StoreDetail, StoreListItem } from "@/types/api";
export async function getStoreCategories(): Promise<StoreCategory[]> {
const res = await winofyFetch<PaginatedResponse<StoreCategory>>("/v1/store-categories/", {
auth: false,
params: { limit: 100 },
});
return res.results;
}
export interface GetStoresParams {
category?: string;
search?: string;
neighborhood?: string;
lat?: number;
lng?: number;
limit?: number;
}
export async function getStores(params: GetStoresParams = {}): Promise<StoreListItem[]> {
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
auth: false,
params: { limit: 20, ...params },
});
return res.results;
}
export async function getStore(uuid: string): Promise<StoreDetail> {
return winofyFetch<StoreDetail>(`/v1/stores/${uuid}/`, { auth: false });
}

View file

@ -1,6 +1,7 @@
import "server-only";
import { getValidSession } from "@/lib/auth/session";
import { ApiError, type WinofyErrorDetails } from "./errors";
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
@ -20,6 +21,7 @@ export interface PaginatedResponse<T> {
export async function winofyFetch<T>(path: string, options: WinofyFetchOptions = {}): Promise<T> {
const { auth = true, params, headers, body, ...rest } = options;
const method = rest.method ?? "GET";
const url = new URL(BASE_URL.replace(/\/$/, "") + path);
if (params) {
@ -39,6 +41,8 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
}
logWinofyRequest(method, url.toString(), body);
const res = await fetch(url.toString(), {
...rest,
headers: finalHeaders,
@ -46,9 +50,13 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
cache: "no-store",
});
if (res.status === 204) return undefined as T;
if (res.status === 204) {
logWinofyResponse(method, url.toString(), res.status, null);
return undefined as T;
}
const data = await res.json().catch(() => null);
logWinofyResponse(method, url.toString(), res.status, data);
if (!res.ok) {
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;

View file

@ -1,13 +1,17 @@
/**
* Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman.
* Shared API types, mirrored from winofyapifrontendintegrationguide.md + the
* OpenAPI spec (source of truth for field names/shapes). Money fields are
* integer Toman. Grouped to match src/lib/api/'s feature layout.
*
* Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys, not
* URLs — per FRONTEND_GUIDE.md §4.7 (feature/minio-integration), every one of them
* has a `<field>_url` sibling that's a freshly presigned, already-absolute URL.
* Always render the `_url` field directly; never resolve the raw key yourself, and
* don't cache `_url` past the response — it's re-signed (and can change) on every read.
* Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys,
* not URLs — every one has a `<field>_url` sibling that's a freshly presigned,
* already-absolute URL. Always render the `_url` field directly; never resolve
* the raw key yourself, and don't cache `_url` past the response — it's
* re-signed (and can change) on every read.
*/
// ---- Locations --------------------------------------------------------------
export interface City {
uuid: string;
name: string;
@ -17,10 +21,13 @@ export interface City {
export interface Neighborhood {
uuid: string;
name: string;
slug?: string;
/** Nested object, confirmed against staging — not a bare uuid string. */
city: City;
}
// ---- Stores / Catalog ---------------------------------------------------
export interface StoreCategory {
uuid: string;
name: string;
@ -46,11 +53,10 @@ export interface StoreListItem {
is_open: boolean;
}
export interface WorkingHours {
weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Saturday .. 6 = Friday
opens_at: string;
closes_at: string;
opens_at: string | null;
closes_at: string | null;
is_closed: boolean;
}
@ -92,6 +98,8 @@ export interface ProductDetail extends ProductListItem {
store: StoreListItem;
}
// ---- Addresses ----------------------------------------------------------
export type AddressLabel = "home" | "work" | "other";
export interface Address {
@ -112,6 +120,8 @@ export interface Address {
created_at: string;
}
// ---- Cart -----------------------------------------------------------------
export interface CartItem {
uuid: string;
product: ProductListItem;
@ -133,20 +143,16 @@ export interface Cart {
grand_total: number;
}
// ---- Checkout / Orders ---------------------------------------------------
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 type OrderStatus = "placed" | "preparing" | "ready_to_ship" | "handed_to_courier" | "delivered" | "cancelled";
export interface OrderItem {
uuid: string;
product: string;
product: string | null;
product_name_snapshot: string;
unit_price_snapshot: number;
quantity: number;
@ -195,3 +201,112 @@ export interface OrderGroup {
/** `feature/payment` only. */
payment?: { method: PaymentMethod; status: string; payment_url?: string };
}
// ---- Reviews --------------------------------------------------------------
export interface Review {
uuid: string;
store: string;
product: string | null;
rating: number;
comment: string;
seller_reply: string | null;
customer_name: string;
created_at: string;
}
// ---- Notifications --------------------------------------------------------
export type NotificationType = "new_order" | "settlement_done" | "new_review" | "order_cancelled";
export interface Notification {
uuid: string;
type: NotificationType;
title: string;
body: string;
related_order: string | null;
is_read: boolean;
created_at: string;
}
// ---- Seller · Store ---------------------------------------------------
export type SellerStoreStatus = "pending" | "approved" | "suspended";
export interface StoreBankAccount {
iban: string;
account_holder_name: string;
}
export interface SellerStore {
uuid: string;
name: string;
category: StoreCategory;
description: string;
logo: string | null;
logo_url: string | null;
cover_image: string | null;
cover_image_url: string | null;
phone_number: string;
city: City;
address: string;
service_neighborhoods: Neighborhood[];
/** DRF decimal-as-string, e.g. "5.00". */
delivery_radius_km: string;
min_order_amount: number;
delivery_fee: number;
free_delivery_threshold: number | null;
accepts_wallet: boolean;
accepts_online: boolean;
accepts_cash_on_delivery: boolean;
rating_avg: string;
rating_count: number;
status: SellerStoreStatus;
is_open: boolean;
bank_account: StoreBankAccount | null;
created_at: string;
}
// ---- Seller · Products ---------------------------------------------------
export interface SellerProduct {
uuid: string;
name: string;
description: string;
image: string | null;
image_url: string | null;
category: ProductCategory;
price: number;
unit_type: UnitType;
unit_value: number;
stock_quantity: number;
low_stock_threshold: number;
is_active: boolean;
sold_count: number;
is_out_of_stock: boolean;
is_low_stock: boolean;
created_at: string;
}
// ---- Seller · Analytics ---------------------------------------------------
export interface SalesChartPoint {
bucket: string;
sales: number;
}
export interface TopProduct {
name: string;
units_sold: number;
revenue: number;
}
export interface SellerAnalytics {
period: string;
order_count: number;
total_sales: number;
cancellation_rate: number;
average_order_value: number;
chart: SalesChartPoint[];
top_products: TopProduct[];
}