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/public/satelliet.png b/public/satelliet.png new file mode 100644 index 0000000..11cb309 Binary files /dev/null and b/public/satelliet.png differ diff --git a/src/app/(customer)/(shop)/addresses/page.tsx b/src/app/(customer)/(shop)/addresses/page.tsx index 505294d..ae25141 100644 --- a/src/app/(customer)/(shop)/addresses/page.tsx +++ b/src/app/(customer)/(shop)/addresses/page.tsx @@ -1,26 +1,8 @@ -import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; import { requireSession } from "@/lib/auth/session"; -import type { Address, City, Neighborhood } from "@/types/api"; +import { getAddresses } from "@/lib/api/addresses"; +import { getCities, getNeighborhoods } from "@/lib/api/locations"; import { AddressList } from "@/components/customer/address-list"; -async function getAddresses() { - const res = await winofyFetch | Address[]>("/v1/addresses/"); - return Array.isArray(res) ? res : res.results; -} - -async function getCities() { - const res = await winofyFetch | City[]>("/v1/cities/", { auth: false }); - return Array.isArray(res) ? res : res.results; -} - -async function getNeighborhoods() { - const res = await winofyFetch | Neighborhood[]>("/v1/neighborhoods/", { - auth: false, - params: { limit: 200 }, - }); - return Array.isArray(res) ? res : res.results; -} - export default async function AddressesPage() { await requireSession("/addresses"); const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]); diff --git a/src/app/(customer)/(shop)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx index 331d8ec..31bb900 100644 --- a/src/app/(customer)/(shop)/cart/page.tsx +++ b/src/app/(customer)/(shop)/cart/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { getCart } from "@/lib/actions/cart"; +import { getCart } from "@/lib/api/cart"; import { requireSession } from "@/lib/auth/session"; import { CartItemRow } from "@/components/customer/cart-item-row"; import { Button } from "@/components/ui/button"; diff --git a/src/app/(customer)/(shop)/checkout/page.tsx b/src/app/(customer)/(shop)/checkout/page.tsx index d60797f..2182a1b 100644 --- a/src/app/(customer)/(shop)/checkout/page.tsx +++ b/src/app/(customer)/(shop)/checkout/page.tsx @@ -1,15 +1,9 @@ import { redirect } from "next/navigation"; -import { getCart } from "@/lib/actions/cart"; +import { getCart } from "@/lib/api/cart"; import { requireSession } from "@/lib/auth/session"; -import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; -import type { Address } from "@/types/api"; +import { getAddresses } from "@/lib/api/addresses"; import { CheckoutForm } from "@/components/customer/checkout-form"; -async function getAddresses() { - const res = await winofyFetch | Address[]>("/v1/addresses/"); - return Array.isArray(res) ? res : res.results; -} - export default async function CheckoutPage() { await requireSession("/checkout"); const [cart, addresses] = await Promise.all([getCart(), getAddresses()]); diff --git a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx index 5563862..5312d7d 100644 --- a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx @@ -1,23 +1,17 @@ import { notFound } from "next/navigation"; -import { winofyFetch, ApiError } from "@/lib/api/winofy"; +import { ApiError } from "@/lib/api/winofy"; import { requireSession } from "@/lib/auth/session"; +import { getOrderGroup } from "@/lib/api/orders"; import { OrderCard } from "@/components/customer/order-card"; import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; -import type { OrderGroup } from "@/types/api"; - -async function getOrderGroup(uuid: string) { - try { - return await winofyFetch(`/v1/order-groups/${uuid}/`); - } catch (err) { - if (err instanceof ApiError && err.status === 404) return null; - throw err; - } -} export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) { const { uuid } = await params; await requireSession(`/order-groups/${uuid}`); - const orderGroup = await getOrderGroup(uuid); + const orderGroup = await getOrderGroup(uuid).catch((err) => { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + }); if (!orderGroup) notFound(); return ( diff --git a/src/app/(customer)/(shop)/orders/page.tsx b/src/app/(customer)/(shop)/orders/page.tsx index f20fc51..ecdd790 100644 --- a/src/app/(customer)/(shop)/orders/page.tsx +++ b/src/app/(customer)/(shop)/orders/page.tsx @@ -1,13 +1,7 @@ import Link from "next/link"; -import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; import { requireSession } from "@/lib/auth/session"; +import { getOrderGroups } from "@/lib/api/orders"; import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; -import type { OrderGroup } from "@/types/api"; - -async function getOrderGroups() { - const res = await winofyFetch>("/v1/order-groups/"); - return res.results; -} export default async function OrdersPage() { await requireSession("/orders"); diff --git a/src/app/(customer)/(shop)/products/[uuid]/page.tsx b/src/app/(customer)/(shop)/products/[uuid]/page.tsx index 1c5927b..e2fbc5e 100644 --- a/src/app/(customer)/(shop)/products/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/products/[uuid]/page.tsx @@ -1,53 +1,46 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { winofyFetch, ApiError } from "@/lib/api/winofy"; -import type { ProductDetail } from "@/types/api"; -import { AddToCartButton } from "@/components/customer/add-to-cart-button"; - -const UNIT_LABELS_FA: Record = { gram: "گرم", ml: "میلی‌لیتر", piece: "عدد" }; - -async function getProduct(uuid: string) { - try { - return await winofyFetch(`/v1/products/${uuid}/`, { auth: false }); - } catch (err) { - if (err instanceof ApiError && err.status === 404) return null; - throw err; - } -} +import { ApiError } from "@/lib/api/winofy"; +import { getProduct } from "@/lib/api/catalog"; +import { HeroBackButton } from "@/components/ui/hero-back-button"; +import { ProductPurchasePanel } from "@/components/product/product-purchase-panel"; export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) { const { uuid } = await params; - const product = await getProduct(uuid); + const product = await getProduct(uuid).catch((err) => { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + }); if (!product) notFound(); return ( -
- - ← بازگشت به {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 c0aa9f7..9102f9a 100644 --- a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx @@ -1,135 +1,69 @@ -import Link from "next/link"; import { notFound } from "next/navigation"; -import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy"; -import type { ProductListItem, StoreDetail } from "@/types/api"; -import { AddToCartButton } from "@/components/customer/add-to-cart-button"; +import { ApiError } from "@/lib/api/winofy"; +import { getStore } from "@/lib/api/stores"; +import { getProducts } from "@/lib/api/catalog"; +import { getCart } from "@/lib/api/cart"; +import { getValidSession } from "@/lib/auth/session"; +import { HeroBackButton } from "@/components/ui/hero-back-button"; +import { StoreInfoCard } from "@/components/store/store-info-card"; +import { StoreSearchBar } from "@/components/store/store-search-bar"; +import { ProductGridCard } from "@/components/store/product-grid-card"; +import { StickyCartBar } from "@/components/store/sticky-cart-bar"; +import type { CartItem } from "@/types/api"; -const WEEKDAY_LABELS_FA = ["شنبه", "یک‌شنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه"]; +export default async function StoreDetailPage({ + params, + searchParams, +}: PageProps<"/stores/[uuid]">) { + const { uuid } = await params; + const { search: searchRaw } = await searchParams; + const search = typeof searchRaw === "string" ? searchRaw : undefined; -async function getStore(uuid: string) { - try { - return await winofyFetch(`/v1/stores/${uuid}/`, { auth: false }); - } catch (err) { + const store = await getStore(uuid).catch((err) => { if (err instanceof ApiError && err.status === 404) return null; throw err; - } -} - -async function getStoreProducts(uuid: string) { - const res = await winofyFetch>("/v1/products/", { - auth: false, - params: { store: uuid, limit: 100 }, }); - return res.results; -} - -export default async function StoreDetailPage({ params }: PageProps<"/stores/[uuid]">) { - const { uuid } = await params; - const store = await getStore(uuid); if (!store) notFound(); - const products = await getStoreProducts(uuid); + const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]); + const cart = session ? await getCart() : null; + const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null; + + const cartItemByProduct = new Map(); + 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 9478550..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,23 +53,26 @@ function LocationErrorPageInner() { router.push(next); router.refresh(); }, - () => setRetrying(false), + (error) => { + setRetrying(false); + router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`); + }, { timeout: 10000 }, ); } return (
- - - -
-

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

-

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

+ {/* 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 7ac9986..93c1455 100644 --- a/src/app/(customer)/location/permission/page.tsx +++ b/src/app/(customer)/location/permission/page.tsx @@ -12,8 +12,7 @@ const BENEFITS = [ { icon: Store, text: "کشف بازارچه‌های اطراف" }, ]; -// L01 — winofyfrontenddoc (1).md §4.1. No illustration asset was provided; -// approximated with a simple icon badge rather than the isometric map graphic. +// L01 — winofyfrontenddoc (1).md §4.1. export default function LocationPermissionPage() { return ( @@ -32,55 +31,69 @@ function LocationPermissionPageInner() { setLoading(true); navigator.geolocation.getCurrentPosition( (position) => { - setLocation({ mode: "geo", lat: position.coords.latitude, lng: position.coords.longitude }); + setLocation({ + mode: "geo", + lat: position.coords.latitude, + lng: position.coords.longitude, + }); router.push(next); router.refresh(); }, - () => { + (error) => { setLoading(false); - router.push(`/location/error?next=${encodeURIComponent(next)}`); + router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`); }, { timeout: 10000 }, ); } return ( -
- - - +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + -
-

بازارچه محلی شما

-

برای نمایش فروشگاه‌های نزدیک، به موقعیت مکانی شما نیاز داریم

-
+
+
+

+ بازارچه محلی شما +

+

+ برای نمایش فروشگاه‌های نزدیک، به موقعیت مکانی شما نیاز داریم +

+
-
- {BENEFITS.map(({ icon: Icon, text }) => ( -
- - {text} -
- ))} -
+
+ {BENEFITS.map(({ icon: Icon, text }) => ( +
+ + {text} +
+ ))} +
-
- - - پشتیبانی +
+ + + پشتیبانی +
); diff --git a/src/app/(customer)/location/search/page.tsx b/src/app/(customer)/location/search/page.tsx index fcb5d8b..c2bc65c 100644 --- a/src/app/(customer)/location/search/page.tsx +++ b/src/app/(customer)/location/search/page.tsx @@ -1,17 +1,8 @@ import { Suspense } from "react"; -import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; import { getLocationServer } from "@/lib/location/server"; -import type { Neighborhood } from "@/types/api"; +import { getNeighborhoods } from "@/lib/api/locations"; import { NeighborhoodSearch } from "@/components/location/neighborhood-search"; -async function getNeighborhoods() { - const res = await winofyFetch | Neighborhood[]>("/v1/neighborhoods/", { - auth: false, - params: { limit: 200 }, - }); - return Array.isArray(res) ? res : res.results; -} - export default async function NeighborhoodSearchPage() { const [neighborhoods, location] = await Promise.all([getNeighborhoods(), getLocationServer()]); const selectedNeighborhoodUuid = location?.mode === "manual" ? location.neighborhoodUuid : null; diff --git a/src/app/(customer)/page.tsx b/src/app/(customer)/page.tsx index 99b0ec8..25d7348 100644 --- a/src/app/(customer)/page.tsx +++ b/src/app/(customer)/page.tsx @@ -1,9 +1,8 @@ import { Suspense } from "react"; import { getValidSession } from "@/lib/auth/session"; import { getLocationServer } from "@/lib/location/server"; -import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy"; -import { getCart } from "@/lib/actions/cart"; -import type { StoreCategory, StoreListItem } from "@/types/api"; +import { getStoreCategories, getStores } from "@/lib/api/stores"; +import { getCart } from "@/lib/api/cart"; import { SiteHeader } from "@/components/home/site-header"; import { SearchBar } from "@/components/home/search-bar"; import { LocationReminderBanner } from "@/components/home/location-reminder-banner"; @@ -12,21 +11,6 @@ import { StoreCard } from "@/components/home/store-card"; import { SellerPromoCard } from "@/components/home/seller-promo-card"; import { BottomNav } from "@/components/home/bottom-nav"; -async function getStoreCategories() { - const res = await winofyFetch | StoreCategory[]>("/v1/store-categories/", { - auth: false, - }); - return Array.isArray(res) ? res : res.results; -} - -async function getStores(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) { - const res = await winofyFetch>("/v1/stores/", { - auth: false, - params: { limit: 20, ...params }, - }); - return res.results; -} - export default async function HomePage({ searchParams }: PageProps<"/">) { const params = await searchParams; const category = typeof params.category === "string" ? params.category : undefined; @@ -62,8 +46,8 @@ export default async function HomePage({ searchParams }: PageProps<"/">) {
)} -
-

فروشگاه‌های نزدیک

+
+

فروشگاه‌های نزدیک

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..2da899b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ * don't invent any rather than guess. */ :root { - --background: #f7f2fa; /* پس‌زمینه — app background */ + --background: #FFFFFF; /* پس‌زمینه — app background */ --surface: #fffbfe; /* سفید — card/sheet surface */ --foreground: #111827; /* gray-900 */ @@ -24,8 +24,16 @@ --teal: #2e807a; --teal-light: #e0f2f1; --teal-bg: #e6f0f0; + --teal-border: #a7f3d0; --blue-bg: #ecf2f8; + /* designspec.md (C05/C06) §1.2 — exact hex, not Tailwind's OKLCH-based emerald/slate defaults */ + --page-bg: #f8fafc; + --emerald-bg: #ecfdf5; + --emerald-border: #a7f3d0; + --emerald-text-strong: #065f46; + --emerald-500: #10b981; + --gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-300: #e5e7eb; @@ -60,8 +68,15 @@ --color-teal: var(--teal); --color-teal-light: var(--teal-light); --color-teal-bg: var(--teal-bg); + --color-teal-border: var(--teal-border); --color-blue-bg: var(--blue-bg); + --color-page-bg: var(--page-bg); + --color-emerald-bg: var(--emerald-bg); + --color-emerald-border: var(--emerald-border); + --color-emerald-text-strong: var(--emerald-text-strong); + --color-emerald-500: var(--emerald-500); + --color-gray-50: var(--gray-50); --color-gray-100: var(--gray-100); --color-gray-300: var(--gray-300); diff --git a/src/components/customer/add-to-cart-button.tsx b/src/components/customer/add-to-cart-button.tsx index cfc6354..f860672 100644 --- a/src/components/customer/add-to-cart-button.tsx +++ b/src/components/customer/add-to-cart-button.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { addToCart } from "@/lib/actions/cart"; +import { addToCart } from "@/lib/api/cart"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils/cn"; diff --git a/src/components/customer/address-list.tsx b/src/components/customer/address-list.tsx index badbda8..c80af60 100644 --- a/src/components/customer/address-list.tsx +++ b/src/components/customer/address-list.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { createAddress, deleteAddress, type AddressInput } from "@/lib/actions/addresses"; +import { createAddress, deleteAddress, type AddressInput } from "@/lib/api/addresses"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { Address, AddressLabel, City, Neighborhood } from "@/types/api"; diff --git a/src/components/customer/cart-item-row.tsx b/src/components/customer/cart-item-row.tsx index 87fb5fd..923824b 100644 --- a/src/components/customer/cart-item-row.tsx +++ b/src/components/customer/cart-item-row.tsx @@ -2,7 +2,7 @@ import { useTransition } from "react"; import { useRouter } from "next/navigation"; -import { updateCartItemQuantity, removeCartItem } from "@/lib/actions/cart"; +import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart"; import type { CartItem } from "@/types/api"; export function CartItemRow({ item }: { item: CartItem }) { diff --git a/src/components/customer/checkout-form.tsx b/src/components/customer/checkout-form.tsx index 9468bd7..a8af62d 100644 --- a/src/components/customer/checkout-form.tsx +++ b/src/components/customer/checkout-form.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { submitCheckout } from "@/lib/actions/checkout"; +import { submitCheckout } from "@/lib/api/checkout"; import { Button } from "@/components/ui/button"; import type { Address, Cart, DeliveryType, PaymentMethod } from "@/types/api"; diff --git a/src/components/customer/order-card.tsx b/src/components/customer/order-card.tsx index 9e1f482..c8371f4 100644 --- a/src/components/customer/order-card.tsx +++ b/src/components/customer/order-card.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { cancelOrder } from "@/lib/actions/orders"; +import { cancelOrder } from "@/lib/api/orders"; import { Button } from "@/components/ui/button"; import { ORDER_STATUS_LABELS_FA, ORDER_STATUS_SEQUENCE } from "@/lib/order-status"; import type { Order } from "@/types/api"; diff --git a/src/components/home/category-chips.tsx b/src/components/home/category-chips.tsx index cf8e6de..082dc7c 100644 --- a/src/components/home/category-chips.tsx +++ b/src/components/home/category-chips.tsx @@ -18,13 +18,15 @@ export function CategoryChips({ categories }: { categories: StoreCategory[] }) { } return ( -
+
-

انتخاب محله

+

انتخاب محله

- + setQuery(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} - placeholder="با نام محله خود را جستجو کنید" - className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-500 focus:outline-none" + placeholder="شهر یا محله خود را جستجو کنید" + className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-600 focus:outline-none" autoFocus />
@@ -106,17 +138,21 @@ export function NeighborhoodSearch({ )} + {state === "searching" && ( +

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

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

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

-

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

+

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

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

نتایج جستجو

+
+

نتایج جستجو

{results.map((n) => ( ))} @@ -125,7 +161,7 @@ export function NeighborhoodSearch({ {(state === "idle" || state === "empty") && (
-

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

+

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

{suggestions.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/actions/orders.ts b/src/lib/actions/orders.ts deleted file mode 100644 index 8325f69..0000000 --- a/src/lib/actions/orders.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { winofyFetch } from "@/lib/api/winofy"; -import { callApi, type ActionResult } from "./result"; -import type { Order } from "@/types/api"; - -export async function cancelOrder(uuid: string, reason: string = ""): Promise> { - const result = await callApi(() => - winofyFetch(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { reason } }), - ); - if (result.ok) { - revalidatePath(`/orders/${uuid}`); - revalidatePath("/orders"); - } - return result; -} diff --git a/src/lib/actions/addresses.ts b/src/lib/api/addresses.ts similarity index 78% rename from src/lib/actions/addresses.ts rename to src/lib/api/addresses.ts index 55324bb..e2c748a 100644 --- a/src/lib/actions/addresses.ts +++ b/src/lib/api/addresses.ts @@ -1,10 +1,15 @@ "use server"; import { revalidatePath } from "next/cache"; -import { winofyFetch } from "@/lib/api/winofy"; +import { winofyFetch, type PaginatedResponse } from "./winofy"; import { callApi, type ActionResult } from "./result"; import type { Address, AddressLabel } from "@/types/api"; +export async function getAddresses(): Promise { + const res = await winofyFetch>("/v1/addresses/"); + return res.results; +} + export interface AddressInput { label: AddressLabel; title: string; @@ -22,9 +27,7 @@ export interface AddressInput { } export async function createAddress(input: AddressInput): Promise> { - const result = await callApi(() => - winofyFetch
("/v1/addresses/", { method: "POST", body: input }), - ); + const result = await callApi(() => winofyFetch
("/v1/addresses/", { method: "POST", body: input })); if (result.ok) revalidatePath("/addresses"); return result; } diff --git a/src/lib/actions/cart.ts b/src/lib/api/cart.ts similarity index 71% rename from src/lib/actions/cart.ts rename to src/lib/api/cart.ts index e013ba3..c2d3c53 100644 --- a/src/lib/actions/cart.ts +++ b/src/lib/api/cart.ts @@ -1,27 +1,25 @@ "use server"; import { revalidatePath } from "next/cache"; -import { winofyFetch } from "@/lib/api/winofy"; +import { winofyFetch } from "./winofy"; import { callApi, type ActionResult } from "./result"; -import type { Cart } from "@/types/api"; +import type { Cart, CartItem } from "@/types/api"; -export async function addToCart(productUuid: string, quantity: number = 1): Promise { +export async function getCart(): Promise { + return winofyFetch("/v1/cart/"); +} + +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; @@ -38,7 +36,3 @@ export async function clearCart(): Promise { if (result.ok) revalidatePath("/cart"); return result; } - -export async function getCart(): Promise { - return winofyFetch("/v1/cart/"); -} diff --git a/src/lib/api/catalog.ts b/src/lib/api/catalog.ts new file mode 100644 index 0000000..08fd304 --- /dev/null +++ b/src/lib/api/catalog.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { winofyFetch, type PaginatedResponse } from "./winofy"; +import type { ProductCategory, ProductDetail, ProductListItem } from "@/types/api"; + +export async function getProductCategories(): Promise { + const res = await winofyFetch>("/v1/product-categories/", { + auth: false, + params: { limit: 100 }, + }); + return res.results; +} + +export interface GetProductsParams { + store?: string; + category?: string; + search?: string; + limit?: number; +} + +export async function getProducts(params: GetProductsParams = {}): Promise { + const res = await winofyFetch>("/v1/products/", { + auth: false, + params: { limit: 100, ...params }, + }); + return res.results; +} + +export async function getProduct(uuid: string): Promise { + return winofyFetch(`/v1/products/${uuid}/`, { auth: false }); +} diff --git a/src/lib/actions/checkout.ts b/src/lib/api/checkout.ts similarity index 91% rename from src/lib/actions/checkout.ts rename to src/lib/api/checkout.ts index c876497..dc10ce5 100644 --- a/src/lib/actions/checkout.ts +++ b/src/lib/api/checkout.ts @@ -1,6 +1,6 @@ "use server"; -import { winofyFetch } from "@/lib/api/winofy"; +import { winofyFetch } from "./winofy"; import { callApi, type ActionResult } from "./result"; import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api"; diff --git a/src/lib/api/dev-logger.ts b/src/lib/api/dev-logger.ts new file mode 100644 index 0000000..b7c3b49 --- /dev/null +++ b/src/lib/api/dev-logger.ts @@ -0,0 +1,62 @@ +import "server-only"; + +const DEBUG = process.env.NODE_ENV !== "production"; +const MAX_ARRAY_ITEMS = 3; + +const RESET = "\x1b[0m"; +const CYAN = "\x1b[36m"; +const GREEN = "\x1b[32m"; +const YELLOW = "\x1b[33m"; +const RED = "\x1b[31m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; + +function statusColor(status: number): string { + if (status >= 500) return RED; + if (status >= 400) return YELLOW; + return GREEN; +} + +/** + * Keeps big paginated lists (e.g. a 19-store `results` array) from flooding the + * terminal — shows the first few items of any array plus a count, recursively. + */ +function truncateForLog(value: unknown): unknown { + if (Array.isArray(value)) { + const shown = value.slice(0, MAX_ARRAY_ITEMS).map(truncateForLog); + if (value.length > MAX_ARRAY_ITEMS) shown.push(`… ${value.length - MAX_ARRAY_ITEMS} more item(s)`); + return shown; + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, truncateForLog(v)])); + } + return value; +} + +function prettyJson(value: unknown): string { + return JSON.stringify(truncateForLog(value), null, 2); +} + +function indent(text: string): string { + return text + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + +/** + * Dev-only request/response logging — the terminal-equivalent of Chrome's Network + * tab for server-side calls, since they run on the Next.js server and the browser + * never sees them (no CORS on the Winofy API). Never runs in production. + */ +export function logWinofyRequest(method: string, url: string, body: unknown) { + if (!DEBUG) return; + console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`); + if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`); +} + +export function logWinofyResponse(method: string, url: string, status: number, data: unknown) { + if (!DEBUG) return; + console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`); + if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`); +} diff --git a/src/lib/api/locations.ts b/src/lib/api/locations.ts new file mode 100644 index 0000000..0241bd4 --- /dev/null +++ b/src/lib/api/locations.ts @@ -0,0 +1,36 @@ +import "server-only"; +import { winofyFetch, type PaginatedResponse } from "./winofy"; +import type { City, Neighborhood } from "@/types/api"; + +export async function getCities(): Promise { + const res = await winofyFetch>("/v1/cities/", { auth: false, params: { limit: 100 } }); + return res.results; +} + +export async function getCity(uuid: string): Promise { + return winofyFetch(`/v1/cities/${uuid}/`, { auth: false }); +} + +export interface GetNeighborhoodsParams { + city?: string; + /** + * Forwarded to the API for forward-compatibility, but confirmed against + * staging (2026-08-22) to currently have no effect — the endpoint returns + * the same unfiltered results with or without it. Callers must still filter + * client-side (see NeighborhoodSearch) until the backend actually supports it. + */ + search?: string; + limit?: number; +} + +export async function getNeighborhoods(params: GetNeighborhoodsParams = {}): Promise { + const res = await winofyFetch>("/v1/neighborhoods/", { + auth: false, + params: { limit: 200, ...params }, + }); + return res.results; +} + +export async function getNeighborhood(uuid: string): Promise { + return winofyFetch(`/v1/neighborhoods/${uuid}/`, { auth: false }); +} diff --git a/src/lib/api/media.ts b/src/lib/api/media.ts new file mode 100644 index 0000000..8463cbf --- /dev/null +++ b/src/lib/api/media.ts @@ -0,0 +1,22 @@ +"use server"; + +import { winofyFetch } from "./winofy"; +import { callApi, type ActionResult } from "./result"; + +export interface MediaPresignResult { + upload_url: string; + object_key: string; +} + +/** + * Step 1 of the upload flow (§2 of the API guide): get a presigned MinIO PUT + * URL. Step 2 — PUT the raw file bytes to `upload_url` directly (client-side, + * no auth header, this is a MinIO URL not a Winofy one) — happens in the + * browser, not here. Step 3 — send the returned `object_key` as the value of + * whatever field you're setting (Store.logo, Product.image, etc.). + */ +export async function presignUpload(filename: string): Promise> { + return callApi(() => + winofyFetch("/media/presign/", { method: "POST", body: { filename } }), + ); +} diff --git a/src/lib/api/notifications.ts b/src/lib/api/notifications.ts new file mode 100644 index 0000000..46a6f34 --- /dev/null +++ b/src/lib/api/notifications.ts @@ -0,0 +1,31 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { winofyFetch, type PaginatedResponse } from "./winofy"; +import { callApi, type ActionResult } from "./result"; +import type { Notification } from "@/types/api"; + +export async function getNotifications(): Promise { + const res = await winofyFetch>("/v1/notifications/"); + return res.results; +} + +export async function getNotification(uuid: string): Promise { + return winofyFetch(`/v1/notifications/${uuid}/`); +} + +export async function markNotificationRead(uuid: string): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/notifications/${uuid}/mark-read/`, { method: "POST", body: {} }), + ); + if (result.ok) revalidatePath("/notifications"); + return result; +} + +export async function markAllNotificationsRead(): Promise { + const result = await callApi(() => + winofyFetch("/v1/notifications/mark-all-read/", { method: "POST", body: {} }), + ); + if (result.ok) revalidatePath("/notifications"); + return result; +} diff --git a/src/lib/api/orders.ts b/src/lib/api/orders.ts new file mode 100644 index 0000000..1a4cb78 --- /dev/null +++ b/src/lib/api/orders.ts @@ -0,0 +1,28 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { winofyFetch, type PaginatedResponse } from "./winofy"; +import { callApi, type ActionResult } from "./result"; +import type { Order, OrderGroup } from "@/types/api"; + +export async function getOrderGroups(): Promise { + const res = await winofyFetch>("/v1/order-groups/"); + return res.results; +} + +export async function getOrderGroup(uuid: string): Promise { + return winofyFetch(`/v1/order-groups/${uuid}/`); +} + +/** Single-order tracking — one store's slice of an OrderGroup. */ +export async function getOrder(uuid: string): Promise { + return winofyFetch(`/v1/orders/${uuid}/`); +} + +export async function cancelOrder(uuid: string, cancelReason: string = ""): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { cancel_reason: cancelReason } }), + ); + if (result.ok) revalidatePath("/orders"); + return result; +} diff --git a/src/lib/actions/result.ts b/src/lib/api/result.ts similarity index 80% rename from src/lib/actions/result.ts rename to src/lib/api/result.ts index f256d0a..1002771 100644 --- a/src/lib/actions/result.ts +++ b/src/lib/api/result.ts @@ -1,10 +1,11 @@ import "server-only"; -import { ApiError } from "@/lib/api/winofy"; +import { ApiError } from "./winofy"; export type ActionResult = | { ok: true; data: T } | { ok: false; status: number; message: string; fieldErrors: Record | null }; +/** Wraps a winofyFetch call for use in a Server Action — never throws, always returns a typed result. */ export async function callApi(fn: () => Promise): Promise> { try { const data = await fn(); diff --git a/src/lib/api/reviews.ts b/src/lib/api/reviews.ts new file mode 100644 index 0000000..1347873 --- /dev/null +++ b/src/lib/api/reviews.ts @@ -0,0 +1,23 @@ +"use server"; + +import { winofyFetch, type PaginatedResponse } from "./winofy"; +import { callApi, type ActionResult } from "./result"; +import type { Review } from "@/types/api"; + +export async function getReviews(params: { store?: string; limit?: number } = {}): Promise { + const res = await winofyFetch>("/v1/reviews/", { + auth: false, + params: { limit: 50, ...params }, + }); + return res.results; +} + +export interface CreateReviewInput { + order_uuid: string; + rating: number; + comment: string; +} + +export async function createReview(input: CreateReviewInput): Promise> { + return callApi(() => winofyFetch("/v1/reviews/", { method: "POST", body: input })); +} diff --git a/src/lib/api/seller/analytics.ts b/src/lib/api/seller/analytics.ts new file mode 100644 index 0000000..6800268 --- /dev/null +++ b/src/lib/api/seller/analytics.ts @@ -0,0 +1,8 @@ +import "server-only"; +import { winofyFetch } from "../winofy"; +import type { SellerAnalytics } from "@/types/api"; + +/** No date-range params are documented — the API returns one fixed reporting window. */ +export async function getSellerAnalytics(): Promise { + return winofyFetch("/v1/seller/analytics/"); +} diff --git a/src/lib/api/seller/orders.ts b/src/lib/api/seller/orders.ts new file mode 100644 index 0000000..723fedf --- /dev/null +++ b/src/lib/api/seller/orders.ts @@ -0,0 +1,60 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { winofyFetch, type PaginatedResponse } from "../winofy"; +import { callApi, type ActionResult } from "../result"; +import type { Order, OrderStatus } from "@/types/api"; + +export async function getSellerOrders(status?: OrderStatus): Promise { + const res = await winofyFetch>("/v1/seller/orders/", { + params: { limit: 100, status }, + }); + return res.results; +} + +export async function getSellerOrder(uuid: string): Promise { + return winofyFetch(`/v1/seller/orders/${uuid}/`); +} + +async function sellerOrderAction( + uuid: string, + action: string, + body: Record = {}, +): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/seller/orders/${uuid}/${action}/`, { method: "POST", body }), + ); + if (result.ok) { + revalidatePath("/seller/orders"); + revalidatePath(`/seller/orders/${uuid}`); + } + return result; +} + +export async function cancelSellerOrder(uuid: string, cancelReason: string = ""): Promise> { + return sellerOrderAction(uuid, "cancel", { cancel_reason: cancelReason }); +} + +/** placed → preparing */ +export async function confirmSellerOrder(uuid: string): Promise> { + return sellerOrderAction(uuid, "confirm"); +} + +/** preparing → ready_to_ship */ +export async function markSellerOrderReady(uuid: string): Promise> { + return sellerOrderAction(uuid, "mark-ready"); +} + +/** ready_to_ship → handed_to_courier */ +export async function markSellerOrderShipped( + uuid: string, + courierName: string, + courierPhone: string, +): Promise> { + return sellerOrderAction(uuid, "mark-shipped", { courier_name: courierName, courier_phone: courierPhone }); +} + +/** handed_to_courier → delivered */ +export async function markSellerOrderDelivered(uuid: string): Promise> { + return sellerOrderAction(uuid, "mark-delivered"); +} diff --git a/src/lib/api/seller/products.ts b/src/lib/api/seller/products.ts new file mode 100644 index 0000000..c3b3d56 --- /dev/null +++ b/src/lib/api/seller/products.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { winofyFetch, type PaginatedResponse } from "../winofy"; +import { callApi, type ActionResult } from "../result"; +import type { SellerProduct } from "@/types/api"; + +export async function getSellerProducts(): Promise { + const res = await winofyFetch>("/v1/seller/products/", { params: { limit: 100 } }); + return res.results; +} + +export async function getSellerProduct(uuid: string): Promise { + return winofyFetch(`/v1/seller/products/${uuid}/`); +} + +export interface SellerProductInput { + name: string; + description: string; + image?: string | null; + category_uuid: string; + price: number; + unit_type: SellerProduct["unit_type"]; + unit_value: number; + stock_quantity: number; + low_stock_threshold: number; + is_active: boolean; +} + +export async function createSellerProduct(input: SellerProductInput): Promise> { + const result = await callApi(() => + winofyFetch("/v1/seller/products/", { method: "POST", body: input }), + ); + if (result.ok) revalidatePath("/seller/products"); + return result; +} + +export async function updateSellerProduct( + uuid: string, + input: Partial, +): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/seller/products/${uuid}/`, { method: "PATCH", body: input }), + ); + if (result.ok) revalidatePath("/seller/products"); + return result; +} + +export async function deleteSellerProduct(uuid: string): Promise { + const result = await callApi(() => winofyFetch(`/v1/seller/products/${uuid}/`, { method: "DELETE" })); + if (result.ok) revalidatePath("/seller/products"); + return result; +} + +/** Focused inventory-only update — returns 204, no body, unlike the general PATCH above. */ +export async function updateSellerProductStock( + uuid: string, + input: { stock_quantity: number; low_stock_threshold?: number }, +): Promise { + const result = await callApi(() => + winofyFetch(`/v1/seller/products/${uuid}/stock/`, { method: "PATCH", body: input }), + ); + if (result.ok) revalidatePath("/seller/products"); + return result; +} diff --git a/src/lib/api/seller/reviews.ts b/src/lib/api/seller/reviews.ts new file mode 100644 index 0000000..0d28100 --- /dev/null +++ b/src/lib/api/seller/reviews.ts @@ -0,0 +1,26 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { winofyFetch, type PaginatedResponse } from "../winofy"; +import { callApi, type ActionResult } from "../result"; +import type { Review } from "@/types/api"; + +export async function getSellerReviews(): Promise { + const res = await winofyFetch>("/v1/seller/reviews/", { params: { limit: 100 } }); + return res.results; +} + +/** + * ⚠️ The spec documents this action's request body as the full customer-facing + * review-write shape (order_uuid/rating/comment), which doesn't make sense for + * a reply. Sending `seller_reply` here as the best guess (matches the response + * field name) — confirm the real field name with backend (or from a 400's + * field-error keys) before relying on this. + */ +export async function replySellerReview(uuid: string, replyText: string): Promise> { + const result = await callApi(() => + winofyFetch(`/v1/seller/reviews/${uuid}/reply/`, { method: "POST", body: { seller_reply: replyText } }), + ); + if (result.ok) revalidatePath("/seller/reviews"); + return result; +} diff --git a/src/lib/api/seller/store.ts b/src/lib/api/seller/store.ts new file mode 100644 index 0000000..13042d5 --- /dev/null +++ b/src/lib/api/seller/store.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { winofyFetch, ApiError } from "../winofy"; +import { callApi, type ActionResult } from "../result"; +import type { SellerStore, WorkingHours } from "@/types/api"; + +/** `null` means the seller hasn't created a store yet (404) — not an error state to surface. */ +export async function getSellerStore(): Promise { + try { + return await winofyFetch("/v1/seller/store/"); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + } +} + +export interface SellerStoreInput { + name: string; + category_uuid: string; + description: string; + logo?: string | null; + cover_image?: string | null; + phone_number: string; + city_uuid: string; + address: string; + latitude: number; + longitude: number; + service_neighborhood_uuids: string[]; + delivery_radius_km: string; + min_order_amount: number; + delivery_fee: number; + free_delivery_threshold?: number | null; + accepts_wallet: boolean; + accepts_online: boolean; + accepts_cash_on_delivery: boolean; + is_open: boolean; +} + +export async function createSellerStore(input: SellerStoreInput): Promise> { + const result = await callApi(() => winofyFetch("/v1/seller/store/", { method: "POST", body: input })); + if (result.ok) revalidatePath("/seller"); + return result; +} + +export async function updateSellerStore(input: Partial): Promise> { + const result = await callApi(() => + winofyFetch("/v1/seller/store/", { method: "PATCH", body: input }), + ); + if (result.ok) revalidatePath("/seller"); + return result; +} + +export async function getSellerWorkingHours(): Promise { + return winofyFetch("/v1/seller/store/working-hours/"); +} + +/** Full replace — send all 7 days (weekday 0–6) in one call. */ +export async function setSellerWorkingHours(hours: WorkingHours[]): Promise> { + const result = await callApi(() => + winofyFetch("/v1/seller/store/working-hours/", { method: "PUT", body: hours }), + ); + if (result.ok) revalidatePath("/seller"); + return result; +} diff --git a/src/lib/api/stores.ts b/src/lib/api/stores.ts new file mode 100644 index 0000000..2b0d38e --- /dev/null +++ b/src/lib/api/stores.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { winofyFetch, type PaginatedResponse } from "./winofy"; +import type { StoreCategory, StoreDetail, StoreListItem } from "@/types/api"; + +export async function getStoreCategories(): Promise { + const res = await winofyFetch>("/v1/store-categories/", { + auth: false, + params: { limit: 100 }, + }); + return res.results; +} + +export interface GetStoresParams { + category?: string; + search?: string; + neighborhood?: string; + lat?: number; + lng?: number; + limit?: number; +} + +export async function getStores(params: GetStoresParams = {}): Promise { + const res = await winofyFetch>("/v1/stores/", { + auth: false, + params: { limit: 20, ...params }, + }); + return res.results; +} + +export async function getStore(uuid: string): Promise { + return winofyFetch(`/v1/stores/${uuid}/`, { auth: false }); +} diff --git a/src/lib/api/winofy.ts b/src/lib/api/winofy.ts index 942e336..87487a6 100644 --- a/src/lib/api/winofy.ts +++ b/src/lib/api/winofy.ts @@ -1,6 +1,7 @@ import "server-only"; import { getValidSession } from "@/lib/auth/session"; import { ApiError, type WinofyErrorDetails } from "./errors"; +import { logWinofyRequest, logWinofyResponse } from "./dev-logger"; const BASE_URL = process.env.WINOFY_API_BASE_URL!; @@ -20,6 +21,7 @@ export interface PaginatedResponse { export async function winofyFetch(path: string, options: WinofyFetchOptions = {}): Promise { const { auth = true, params, headers, body, ...rest } = options; + const method = rest.method ?? "GET"; const url = new URL(BASE_URL.replace(/\/$/, "") + path); if (params) { @@ -39,6 +41,8 @@ export async function winofyFetch(path: string, options: WinofyFetchOptions = if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`); } + logWinofyRequest(method, url.toString(), body); + const res = await fetch(url.toString(), { ...rest, headers: finalHeaders, @@ -46,9 +50,13 @@ export async function winofyFetch(path: string, options: WinofyFetchOptions = cache: "no-store", }); - if (res.status === 204) return undefined as T; + if (res.status === 204) { + logWinofyResponse(method, url.toString(), res.status, null); + return undefined as T; + } const data = await res.json().catch(() => null); + logWinofyResponse(method, url.toString(), res.status, data); if (!res.ok) { const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null; diff --git a/src/types/api.ts b/src/types/api.ts index fc80d4d..c0c86e3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,13 +1,17 @@ /** - * Shared API types mirrored from FRONTEND_GUIDE.md. Money fields are integer Toman. + * Shared API types, mirrored from winofyapifrontendintegrationguide.md + the + * OpenAPI spec (source of truth for field names/shapes). Money fields are + * integer Toman. Grouped to match src/lib/api/'s feature layout. * - * Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys, not - * URLs — per FRONTEND_GUIDE.md §4.7 (feature/minio-integration), every one of them - * has a `_url` sibling that's a freshly presigned, already-absolute URL. - * Always render the `_url` field directly; never resolve the raw key yourself, and - * don't cache `_url` past the response — it's re-signed (and can change) on every read. + * Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys, + * not URLs — every one has a `_url` sibling that's a freshly presigned, + * already-absolute URL. Always render the `_url` field directly; never resolve + * the raw key yourself, and don't cache `_url` past the response — it's + * re-signed (and can change) on every read. */ +// ---- Locations -------------------------------------------------------------- + export interface City { uuid: string; name: string; @@ -17,10 +21,13 @@ export interface City { export interface Neighborhood { uuid: string; name: string; + slug?: string; /** Nested object, confirmed against staging — not a bare uuid string. */ city: City; } +// ---- Stores / Catalog --------------------------------------------------- + export interface StoreCategory { uuid: string; name: string; @@ -46,11 +53,10 @@ export interface StoreListItem { is_open: boolean; } - export interface WorkingHours { weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Saturday .. 6 = Friday - opens_at: string; - closes_at: string; + opens_at: string | null; + closes_at: string | null; is_closed: boolean; } @@ -92,6 +98,8 @@ export interface ProductDetail extends ProductListItem { store: StoreListItem; } +// ---- Addresses ---------------------------------------------------------- + export type AddressLabel = "home" | "work" | "other"; export interface Address { @@ -112,6 +120,8 @@ export interface Address { created_at: string; } +// ---- Cart ----------------------------------------------------------------- + export interface CartItem { uuid: string; product: ProductListItem; @@ -133,20 +143,16 @@ export interface Cart { grand_total: number; } +// ---- Checkout / Orders --------------------------------------------------- + export type DeliveryType = "express" | "scheduled"; export type PaymentMethod = "wallet" | "online" | "cash_on_delivery"; export type PaymentStatus = "pending" | "paid" | "failed"; -export type OrderStatus = - | "placed" - | "preparing" - | "ready_to_ship" - | "handed_to_courier" - | "delivered" - | "cancelled"; +export type OrderStatus = "placed" | "preparing" | "ready_to_ship" | "handed_to_courier" | "delivered" | "cancelled"; export interface OrderItem { uuid: string; - product: string; + product: string | null; product_name_snapshot: string; unit_price_snapshot: number; quantity: number; @@ -195,3 +201,112 @@ export interface OrderGroup { /** `feature/payment` only. */ payment?: { method: PaymentMethod; status: string; payment_url?: string }; } + +// ---- Reviews -------------------------------------------------------------- + +export interface Review { + uuid: string; + store: string; + product: string | null; + rating: number; + comment: string; + seller_reply: string | null; + customer_name: string; + created_at: string; +} + +// ---- Notifications -------------------------------------------------------- + +export type NotificationType = "new_order" | "settlement_done" | "new_review" | "order_cancelled"; + +export interface Notification { + uuid: string; + type: NotificationType; + title: string; + body: string; + related_order: string | null; + is_read: boolean; + created_at: string; +} + +// ---- Seller · Store --------------------------------------------------- + +export type SellerStoreStatus = "pending" | "approved" | "suspended"; + +export interface StoreBankAccount { + iban: string; + account_holder_name: string; +} + +export interface SellerStore { + uuid: string; + name: string; + category: StoreCategory; + description: string; + logo: string | null; + logo_url: string | null; + cover_image: string | null; + cover_image_url: string | null; + phone_number: string; + city: City; + address: string; + service_neighborhoods: Neighborhood[]; + /** DRF decimal-as-string, e.g. "5.00". */ + delivery_radius_km: string; + min_order_amount: number; + delivery_fee: number; + free_delivery_threshold: number | null; + accepts_wallet: boolean; + accepts_online: boolean; + accepts_cash_on_delivery: boolean; + rating_avg: string; + rating_count: number; + status: SellerStoreStatus; + is_open: boolean; + bank_account: StoreBankAccount | null; + created_at: string; +} + +// ---- Seller · Products --------------------------------------------------- + +export interface SellerProduct { + uuid: string; + name: string; + description: string; + image: string | null; + image_url: string | null; + category: ProductCategory; + price: number; + unit_type: UnitType; + unit_value: number; + stock_quantity: number; + low_stock_threshold: number; + is_active: boolean; + sold_count: number; + is_out_of_stock: boolean; + is_low_stock: boolean; + created_at: string; +} + +// ---- Seller · Analytics --------------------------------------------------- + +export interface SalesChartPoint { + bucket: string; + sales: number; +} + +export interface TopProduct { + name: string; + units_sold: number; + revenue: number; +} + +export interface SellerAnalytics { + period: string; + order_count: number; + total_sales: number; + cancellation_rate: number; + average_order_value: number; + chart: SalesChartPoint[]; + top_products: TopProduct[]; +}