feat: shop detail page implemented

This commit is contained in:
Afra ‌ 2026-08-23 09:24:22 +03:30
parent 716164ce96
commit 84663e65a0
16 changed files with 600 additions and 148 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,
},

View file

@ -2,9 +2,8 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { ApiError } from "@/lib/api/winofy";
import { getProduct } from "@/lib/api/catalog";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
const UNIT_LABELS_FA: Record<string, string> = { gram: "گرم", ml: "میلی‌لیتر", piece: "عدد" };
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;
@ -15,33 +14,33 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
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-5 px-5 pt-5">
<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}
<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>
<p className="text-lg font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
{product.description && <p className="text-sm text-muted-foreground">{product.description}</p>}
</div>
<AddToCartButton
productUuid={product.uuid}
disabled={!product.is_active || product.is_out_of_stock}
outOfStock={product.is_out_of_stock}
className="w-full"
/>
<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,122 +1,69 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ApiError } from "@/lib/api/winofy";
import { getStore } from "@/lib/api/stores";
import { getProducts } from "@/lib/api/catalog";
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
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 }: PageProps<"/stores/[uuid]">) {
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;
const store = await getStore(uuid).catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
});
if (!store) notFound();
const products = await getProducts({ store: uuid, limit: 100 });
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">
<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>
<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 className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
<HeroBackButton href="/" />
</div>
{store.description && <p className="text-sm text-muted-foreground">{store.description}</p>}
<StoreInfoCard store={store} />
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-3 py-1">
حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان
</span>
<span className="rounded-full bg-muted px-3 py-1">
هزینه ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان
</span>
{store.free_delivery_threshold != null && (
<span className="rounded-full bg-muted px-3 py-1">
ارسال رایگان بالای {store.free_delivery_threshold.toLocaleString("fa-IR")} تومان
</span>
)}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{store.accepts_wallet && <span className="rounded-full border border-border px-3 py-1">کیف پول</span>}
{store.accepts_online && <span className="rounded-full border border-border px-3 py-1">درگاه بانکی</span>}
{store.accepts_cash_on_delivery && (
<span className="rounded-full border border-border px-3 py-1">پرداخت در محل</span>
)}
</div>
{store.working_hours?.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer font-medium">ساعات کاری</summary>
<ul className="mt-2 flex flex-col gap-1 text-muted-foreground">
{store.working_hours.map((wh) => (
<li key={wh.weekday} className="flex justify-between">
<span>{WEEKDAY_LABELS_FA[wh.weekday]}</span>
<span>{wh.is_closed ? "تعطیل" : `${wh.opens_at} تا ${wh.closes_at}`}</span>
</li>
))}
</ul>
</details>
)}
</div>
</div>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">محصولات</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{products.map((product) => (
<article key={product.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-3">
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
{product.image_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>
<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,7 +53,10 @@ function LocationErrorPageInner() {
router.push(next);
router.refresh();
},
() => setRetrying(false),
(error) => {
setRetrying(false);
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
},
{ timeout: 10000 },
);
}
@ -38,11 +64,12 @@ function LocationErrorPageInner() {
return (
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
{/* 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">موقعیت مکانی شناسایی نشد</h1>
<p className="text-body-large text-gray-700 leading-6">مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.</p>
<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-[50%] flex w-full flex-col gap-8">

View file

@ -39,9 +39,9 @@ function LocationPermissionPageInner() {
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 },
);

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

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

@ -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) {
@ -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} />
))}

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

@ -3,23 +3,23 @@
import { revalidatePath } from "next/cache";
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 getCart(): Promise<Cart> {
return winofyFetch<Cart>("/v1/cart/");
}
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult> {
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;

View file

@ -11,7 +11,19 @@ export async function getCity(uuid: string): Promise<City> {
return winofyFetch<City>(`/v1/cities/${uuid}/`, { auth: false });
}
export async function getNeighborhoods(params: { city?: string; limit?: number } = {}): Promise<Neighborhood[]> {
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 },