From 84663e65a0e05af9b59b351818694c145bba7c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sun, 23 Aug 2026 09:24:22 +0330 Subject: [PATCH] feat: shop detail page implemented --- next.config.ts | 10 ++ .../(shop)/products/[uuid]/page.tsx | 45 +++--- .../(customer)/(shop)/stores/[uuid]/page.tsx | 151 ++++++------------ src/app/(customer)/location/error/page.tsx | 35 +++- .../(customer)/location/permission/page.tsx | 4 +- src/app/api/locations/neighborhoods/route.ts | 24 +++ src/app/globals.css | 15 ++ .../location/neighborhood-search.tsx | 58 +++++-- .../product/product-purchase-panel.tsx | 108 +++++++++++++ src/components/store/product-grid-card.tsx | 123 ++++++++++++++ src/components/store/sticky-cart-bar.tsx | 32 ++++ src/components/store/store-info-card.tsx | 71 ++++++++ src/components/store/store-search-bar.tsx | 33 ++++ src/components/ui/hero-back-button.tsx | 15 ++ src/lib/api/cart.ts | 10 +- src/lib/api/locations.ts | 14 +- 16 files changed, 600 insertions(+), 148 deletions(-) create mode 100644 src/app/api/locations/neighborhoods/route.ts create mode 100644 src/components/product/product-purchase-panel.tsx create mode 100644 src/components/store/product-grid-card.tsx create mode 100644 src/components/store/sticky-cart-bar.tsx create mode 100644 src/components/store/store-info-card.tsx create mode 100644 src/components/store/store-search-bar.tsx create mode 100644 src/components/ui/hero-back-button.tsx diff --git a/next.config.ts b/next.config.ts index 8aa5a18..6724b7a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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, }, diff --git a/src/app/(customer)/(shop)/products/[uuid]/page.tsx b/src/app/(customer)/(shop)/products/[uuid]/page.tsx index c9ae6ae..e2fbc5e 100644 --- a/src/app/(customer)/(shop)/products/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/products/[uuid]/page.tsx @@ -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 = { 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 ( -
- - ← بازگشت به {product.store.name} - - -
+
+
{product.image_url && ( // eslint-disable-next-line @next/next/no-img-element {product.name} )} +
-
-

{product.name}

-

- {product.unit_value} {UNIT_LABELS_FA[product.unit_type] ?? product.unit_type} -

-

{product.price.toLocaleString("fa-IR")} تومان

- {product.description &&

{product.description}

} -
+
+
+

{product.name}

+ + {product.store.name} + +

+ {product.price.toLocaleString("fa-IR")} تومان +

+
- + +
+

توضیحات محصول

+

{product.description}

+
+
+
); } diff --git a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx index 7962f98..9102f9a 100644 --- a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx @@ -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(); + storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item)); return ( -
-
-
- {store.cover_image_url && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
-
-
- {store.logo_url ? ( - // eslint-disable-next-line @next/next/no-img-element - {store.name} - ) : ( -
- )} -
-

{store.name}

-

{store.category.name}

-

- ⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count} نظر) ·{" "} - {store.is_open ? "باز" : "بسته"} -

-
-
- - {store.description &&

{store.description}

} - -
- - حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان - - - هزینه ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان - - {store.free_delivery_threshold != null && ( - - ارسال رایگان بالای {store.free_delivery_threshold.toLocaleString("fa-IR")} تومان - - )} -
- -
- {store.accepts_wallet && کیف پول} - {store.accepts_online && درگاه بانکی} - {store.accepts_cash_on_delivery && ( - پرداخت در محل - )} -
- - {store.working_hours?.length > 0 && ( -
- ساعات کاری -
    - {store.working_hours.map((wh) => ( -
  • - {WEEKDAY_LABELS_FA[wh.weekday]} - {wh.is_closed ? "تعطیل" : `${wh.opens_at} تا ${wh.closes_at}`} -
  • - ))} -
-
- )} -
+
+
+ {store.cover_image_url && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +
+
-
-

محصولات

-
- {products.map((product) => ( -
- -
- {product.image_url && ( - // eslint-disable-next-line @next/next/no-img-element - {product.name} - )} -
-

{product.name}

-

{product.price.toLocaleString("fa-IR")} تومان

- - -
- ))} - {products.length === 0 &&

محصولی یافت نشد.

} -
+ + +
+

توضیحات

+

{store.description}

+ +
+ +
+ +
+ {products.map((product) => ( + + ))} + {products.length === 0 && ( +

محصولی یافت نشد.

+ )} +
+ + {storeGroup && }
); } diff --git a/src/app/(customer)/location/error/page.tsx b/src/app/(customer)/location/error/page.tsx index 5346038..e8a9d08 100644 --- a/src/app/(customer)/location/error/page.tsx +++ b/src/app/(customer)/location/error/page.tsx @@ -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 = { + "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 (
+ {/* eslint-disable-next-line @next/next/no-img-element */}
-

موقعیت مکانی شناسایی نشد

-

مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.

+

{title}

+

{body}

diff --git a/src/app/(customer)/location/permission/page.tsx b/src/app/(customer)/location/permission/page.tsx index fd5e47e..93c1455 100644 --- a/src/app/(customer)/location/permission/page.tsx +++ b/src/app/(customer)/location/permission/page.tsx @@ -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 }, ); diff --git a/src/app/api/locations/neighborhoods/route.ts b/src/app/api/locations/neighborhoods/route.ts new file mode 100644 index 0000000..f3f5f65 --- /dev/null +++ b/src/app/api/locations/neighborhoods/route.ts @@ -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); +} diff --git a/src/app/globals.css b/src/app/globals.css index c412c23..82d9e79 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/components/location/neighborhood-search.tsx b/src/components/location/neighborhood-search.tsx index dbf5623..169514a 100644 --- a/src/components/location/neighborhood-search.tsx +++ b/src/components/location/neighborhood-search.tsx @@ -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([]); + 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({ )} + {state === "searching" && ( +

در حال جستجو...

+ )} + {state === "empty" && ( -
+

محله‌ای با این نام پیدا نشد

-

نام محله را بررسی کنید یا محله دیگری را جستجو کنید

+

نام محله را بررسی کنید یا محله دیگری جستجو کنید

)} {state === "results" && ( -
-

نتایج جستجو

+
+

نتایج جستجو

{results.map((n) => ( ))} diff --git a/src/components/product/product-purchase-panel.tsx b/src/components/product/product-purchase-panel.tsx new file mode 100644 index 0000000..5a8d00a --- /dev/null +++ b/src/components/product/product-purchase-panel.tsx @@ -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(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 ( + <> +
+
+ + {toPersianDigits(quantity)} + +
+ + {product.is_out_of_stock ? "ناموجود" : "موجود ●"} + +
+ +
+ + {children} + + {error &&

{error}

} + + {showToast && ( +
+ + + محصول به سبد خرید اضافه شد + + +
+ )} + +
+
+ +
+ جمع کل + + {(product.price * quantity).toLocaleString("fa-IR")} تومان + +
+
+
+
+ + ); +} diff --git a/src/components/store/product-grid-card.tsx b/src/components/store/product-grid-card.tsx new file mode 100644 index 0000000..defcdf8 --- /dev/null +++ b/src/components/store/product-grid-card.tsx @@ -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(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 ( +
+ {error && ( +
+ {error} +
+ )} + +
+ {product.image_url && ( + // eslint-disable-next-line @next/next/no-img-element + {product.name} + )} +
+
+ +
+
+ +

{product.name}

+ +

+ {product.price.toLocaleString("fa-IR")} تومان +

+
+ + {disabled ? ( + ناموجود + ) : quantity === 0 ? ( + + ) : ( +
+ + {quantity.toLocaleString("fa-IR")} + +
+ )} +
+
+ ); +} diff --git a/src/components/store/sticky-cart-bar.tsx b/src/components/store/sticky-cart-bar.tsx new file mode 100644 index 0000000..c0744a8 --- /dev/null +++ b/src/components/store/sticky-cart-bar.tsx @@ -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 ( +
+
+ + + + + {itemCount.toLocaleString("fa-IR")} + + + مشاهده سبد + + +
+ جمع کل + + {total.toLocaleString("fa-IR")} تومان + +
+
+
+
+ ); +} diff --git a/src/components/store/store-info-card.tsx b/src/components/store/store-info-card.tsx new file mode 100644 index 0000000..2f21db1 --- /dev/null +++ b/src/components/store/store-info-card.tsx @@ -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 ( +
+

{store.name}

+

{store.category.name}

+ +
+ {today && ( + + {today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`} + + )} + | + + + + {store.is_open ? "فعال" : "بسته"} + + +
+ +
+ + + + {store.address} + + مسیریابی + +
+ + {store.phone_number && ( + + + + + + {store.phone_number} + + + )} +
+ ); +} diff --git a/src/components/store/store-search-bar.tsx b/src/components/store/store-search-bar.tsx new file mode 100644 index 0000000..e70f44c --- /dev/null +++ b/src/components/store/store-search-bar.tsx @@ -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 ( +
+
+ + 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" + /> +
+
+ ); +} diff --git a/src/components/ui/hero-back-button.tsx b/src/components/ui/hero-back-button.tsx new file mode 100644 index 0000000..13bc036 --- /dev/null +++ b/src/components/ui/hero-back-button.tsx @@ -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 ( + + + + ); +} diff --git a/src/lib/api/cart.ts b/src/lib/api/cart.ts index d26e630..c2d3c53 100644 --- a/src/lib/api/cart.ts +++ b/src/lib/api/cart.ts @@ -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 { return winofyFetch("/v1/cart/"); } -export async function addToCart(productUuid: string, quantity: number = 1): Promise { +export async function addToCart(productUuid: string, quantity: number = 1): Promise> { const result = await callApi(() => - winofyFetch("/v1/cart/items/", { method: "POST", body: { product_uuid: productUuid, quantity } }), + winofyFetch("/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 { +export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise> { const result = await callApi(() => - winofyFetch(`/v1/cart/items/${itemUuid}/`, { method: "PATCH", body: { quantity } }), + winofyFetch(`/v1/cart/items/${itemUuid}/`, { method: "PATCH", body: { quantity } }), ); if (result.ok) revalidatePath("/cart"); return result; diff --git a/src/lib/api/locations.ts b/src/lib/api/locations.ts index 0697884..0241bd4 100644 --- a/src/lib/api/locations.ts +++ b/src/lib/api/locations.ts @@ -11,7 +11,19 @@ export async function getCity(uuid: string): Promise { return winofyFetch(`/v1/cities/${uuid}/`, { auth: false }); } -export async function getNeighborhoods(params: { city?: string; limit?: number } = {}): Promise { +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 { const res = await winofyFetch>("/v1/neighborhoods/", { auth: false, params: { limit: 200, ...params },