feat: shop detail page implemented
This commit is contained in:
parent
716164ce96
commit
84663e65a0
16 changed files with 600 additions and 148 deletions
|
|
@ -2,6 +2,16 @@ import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
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: {
|
turbopack: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ApiError } from "@/lib/api/winofy";
|
import { ApiError } from "@/lib/api/winofy";
|
||||||
import { getProduct } from "@/lib/api/catalog";
|
import { getProduct } from "@/lib/api/catalog";
|
||||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
import { HeroBackButton } from "@/components/ui/hero-back-button";
|
||||||
|
import { ProductPurchasePanel } from "@/components/product/product-purchase-panel";
|
||||||
const UNIT_LABELS_FA: Record<string, string> = { gram: "گرم", ml: "میلیلیتر", piece: "عدد" };
|
|
||||||
|
|
||||||
export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) {
|
export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) {
|
||||||
const { uuid } = await params;
|
const { uuid } = await params;
|
||||||
|
|
@ -15,33 +14,33 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
|
<main className="flex min-h-full flex-1 flex-col bg-white pb-[140px]">
|
||||||
<Link href={`/stores/${product.store.uuid}`} className="text-sm text-primary underline underline-offset-4">
|
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
|
||||||
← بازگشت به {product.store.name}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
|
|
||||||
{product.image_url && (
|
{product.image_url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||||
)}
|
)}
|
||||||
|
<HeroBackButton href={`/stores/${product.store.uuid}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 px-5 pt-5">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-xl font-bold">{product.name}</h1>
|
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-semibold leading-none text-primary-action">
|
||||||
{product.unit_value} {UNIT_LABELS_FA[product.unit_type] ?? product.unit_type}
|
{product.store.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
|
||||||
|
{product.price.toLocaleString("fa-IR")} تومان
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<AddToCartButton
|
<ProductPurchasePanel product={product}>
|
||||||
productUuid={product.uuid}
|
<section className="flex flex-col gap-3">
|
||||||
disabled={!product.is_active || product.is_out_of_stock}
|
<h2 className="text-[18px] font-bold leading-none text-foreground">توضیحات محصول</h2>
|
||||||
outOfStock={product.is_out_of_stock}
|
<p className="text-right text-[15px] leading-[1.6] text-gray-600">{product.description}</p>
|
||||||
className="w-full"
|
</section>
|
||||||
/>
|
</ProductPurchasePanel>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,69 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ApiError } from "@/lib/api/winofy";
|
import { ApiError } from "@/lib/api/winofy";
|
||||||
import { getStore } from "@/lib/api/stores";
|
import { getStore } from "@/lib/api/stores";
|
||||||
import { getProducts } from "@/lib/api/catalog";
|
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,
|
||||||
export default async function StoreDetailPage({ params }: PageProps<"/stores/[uuid]">) {
|
searchParams,
|
||||||
|
}: PageProps<"/stores/[uuid]">) {
|
||||||
const { uuid } = await params;
|
const { uuid } = await params;
|
||||||
|
const { search: searchRaw } = await searchParams;
|
||||||
|
const search = typeof searchRaw === "string" ? searchRaw : undefined;
|
||||||
|
|
||||||
const store = await getStore(uuid).catch((err) => {
|
const store = await getStore(uuid).catch((err) => {
|
||||||
if (err instanceof ApiError && err.status === 404) return null;
|
if (err instanceof ApiError && err.status === 404) return null;
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (!store) notFound();
|
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 (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">
|
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
|
||||||
<div className="overflow-hidden rounded-2xl border border-border">
|
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
|
||||||
<div className="h-32 w-full bg-muted">
|
|
||||||
{store.cover_image_url && (
|
{store.cover_image_url && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<HeroBackButton href="/" />
|
||||||
<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>
|
</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">
|
<section className="px-4 pt-2">
|
||||||
<span className="rounded-full bg-muted px-3 py-1">
|
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
|
||||||
حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان
|
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
|
||||||
</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>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,30 @@
|
||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { MapPinOff } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { setLocation } from "@/lib/location/client";
|
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:
|
// L04 — winofyfrontenddoc (1).md §4.2. CTA hierarchy deliberately flips from L01:
|
||||||
// manual selection is primary here (most likely to succeed), retry is secondary.
|
// manual selection is primary here (most likely to succeed), retry is secondary.
|
||||||
export default function LocationErrorPage() {
|
export default function LocationErrorPage() {
|
||||||
|
|
@ -20,8 +40,11 @@ function LocationErrorPageInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const next = searchParams.get("next") ?? "/";
|
const next = searchParams.get("next") ?? "/";
|
||||||
|
const reason = searchParams.get("reason");
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
|
const { title, body } = (reason ? REASON_MESSAGES[reason] : undefined) ?? DEFAULT_REASON;
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
|
@ -30,7 +53,10 @@ function LocationErrorPageInner() {
|
||||||
router.push(next);
|
router.push(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
() => setRetrying(false),
|
(error) => {
|
||||||
|
setRetrying(false);
|
||||||
|
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
|
||||||
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -38,11 +64,12 @@ function LocationErrorPageInner() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
|
<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}/>
|
<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">
|
<div className="flex flex-col gap-6 my-4">
|
||||||
<h1 className="text-headline-medium font-extrabold text-foreground">موقعیت مکانی شناسایی نشد</h1>
|
<h1 className="text-headline-medium font-extrabold text-foreground">{title}</h1>
|
||||||
<p className="text-body-large text-gray-700 leading-6">مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.</p>
|
<p className="text-body-large text-gray-700 leading-6">{body}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[50%] flex w-full flex-col gap-8">
|
<div className="mt-[50%] flex w-full flex-col gap-8">
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ function LocationPermissionPageInner() {
|
||||||
router.push(next);
|
router.push(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
() => {
|
(error) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
router.push(`/location/error?next=${encodeURIComponent(next)}`);
|
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
|
||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
24
src/app/api/locations/neighborhoods/route.ts
Normal file
24
src/app/api/locations/neighborhoods/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -24,8 +24,16 @@
|
||||||
--teal: #2e807a;
|
--teal: #2e807a;
|
||||||
--teal-light: #e0f2f1;
|
--teal-light: #e0f2f1;
|
||||||
--teal-bg: #e6f0f0;
|
--teal-bg: #e6f0f0;
|
||||||
|
--teal-border: #a7f3d0;
|
||||||
--blue-bg: #ecf2f8;
|
--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-50: #f9fafb;
|
||||||
--gray-100: #f3f4f6;
|
--gray-100: #f3f4f6;
|
||||||
--gray-300: #e5e7eb;
|
--gray-300: #e5e7eb;
|
||||||
|
|
@ -60,8 +68,15 @@
|
||||||
--color-teal: var(--teal);
|
--color-teal: var(--teal);
|
||||||
--color-teal-light: var(--teal-light);
|
--color-teal-light: var(--teal-light);
|
||||||
--color-teal-bg: var(--teal-bg);
|
--color-teal-bg: var(--teal-bg);
|
||||||
|
--color-teal-border: var(--teal-border);
|
||||||
--color-blue-bg: var(--blue-bg);
|
--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-50: var(--gray-50);
|
||||||
--color-gray-100: var(--gray-100);
|
--color-gray-100: var(--gray-100);
|
||||||
--color-gray-300: var(--gray-300);
|
--color-gray-300: var(--gray-300);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ArrowRight, Check, MapPin, Search, SearchX, X } from "lucide-react";
|
import { ArrowRight, Check, MapPin, Search, SearchX, X } from "lucide-react";
|
||||||
import { setLocation, clearLocation } from "@/lib/location/client";
|
import { setLocation, clearLocation } from "@/lib/location/client";
|
||||||
|
|
@ -21,14 +21,46 @@ export function NeighborhoodSearch({
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [results, setResults] = useState<Neighborhood[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
const results = useMemo(() => {
|
// Real, visible fetch to our own /api/locations/neighborhoods route (debounced)
|
||||||
if (!query.trim()) return [];
|
// — not just filtering the initial list in memory.
|
||||||
const q = query.trim();
|
useEffect(() => {
|
||||||
return neighborhoods.filter((n) => n.name.includes(q));
|
const trimmed = query.trim();
|
||||||
}, [neighborhoods, query]);
|
// 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);
|
const suggestions = neighborhoods.slice(0, 8);
|
||||||
|
|
||||||
function selectNeighborhood(n: Neighborhood) {
|
function selectNeighborhood(n: Neighborhood) {
|
||||||
|
|
@ -106,17 +138,21 @@ export function NeighborhoodSearch({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{state === "searching" && (
|
||||||
|
<p className="py-8 text-center text-body-medium text-gray-500">در حال جستجو...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{state === "empty" && (
|
{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" />
|
<SearchX className="h-12 w-12 text-gray-400" />
|
||||||
<h2 className="text-title-medium font-semibold text-foreground">محلهای با این نام پیدا نشد</h2>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "results" && (
|
{state === "results" && (
|
||||||
<div className="mt-4 flex flex-col">
|
<div className="m-4 flex flex-col">
|
||||||
<h2 className="pb-2 text-label text-gray-600">نتایج جستجو</h2>
|
<h2 className="pb-4 text-label text-gray-600">نتایج جستجو</h2>
|
||||||
{results.map((n) => (
|
{results.map((n) => (
|
||||||
<NeighborhoodRow key={n.uuid} n={n} />
|
<NeighborhoodRow key={n.uuid} n={n} />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
108
src/components/product/product-purchase-panel.tsx
Normal file
108
src/components/product/product-purchase-panel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/store/product-grid-card.tsx
Normal file
123
src/components/store/product-grid-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/store/sticky-cart-bar.tsx
Normal file
32
src/components/store/sticky-cart-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/store/store-info-card.tsx
Normal file
71
src/components/store/store-info-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/store/store-search-bar.tsx
Normal file
33
src/components/store/store-search-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/ui/hero-back-button.tsx
Normal file
15
src/components/ui/hero-back-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,23 +3,23 @@
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { winofyFetch } from "./winofy";
|
import { winofyFetch } from "./winofy";
|
||||||
import { callApi, type ActionResult } from "./result";
|
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> {
|
export async function getCart(): Promise<Cart> {
|
||||||
return winofyFetch<Cart>("/v1/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(() =>
|
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");
|
if (result.ok) revalidatePath("/cart");
|
||||||
return result;
|
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(() =>
|
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");
|
if (result.ok) revalidatePath("/cart");
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,19 @@ export async function getCity(uuid: string): Promise<City> {
|
||||||
return winofyFetch<City>(`/v1/cities/${uuid}/`, { auth: false });
|
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/", {
|
const res = await winofyFetch<PaginatedResponse<Neighborhood>>("/v1/neighborhoods/", {
|
||||||
auth: false,
|
auth: false,
|
||||||
params: { limit: 200, ...params },
|
params: { limit: 200, ...params },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue