Merge pull request 'feature/WNPT-733' (#1) from feature/WNPT-733 into main
Reviewed-on: #1
This commit is contained in:
commit
17ee08bd5f
49 changed files with 1338 additions and 377 deletions
|
|
@ -2,6 +2,16 @@ import type { NextConfig } from "next";
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Prints every server-side fetch() (URL, method, cache status) to the
|
||||
// terminal running `next dev` — the only way to see calls like the home
|
||||
// page's stores-by-neighborhood fetch, since those run on the server and
|
||||
// never appear in the browser's Network tab (dev-only; has no effect on
|
||||
// `next build`/`next start`).
|
||||
logging: {
|
||||
fetches: {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
|
|
|
|||
BIN
public/satelliet.png
Normal file
BIN
public/satelliet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 KiB |
|
|
@ -1,26 +1,8 @@
|
|||
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import type { Address, City, Neighborhood } from "@/types/api";
|
||||
import { getAddresses } from "@/lib/api/addresses";
|
||||
import { getCities, getNeighborhoods } from "@/lib/api/locations";
|
||||
import { AddressList } from "@/components/customer/address-list";
|
||||
|
||||
async function getAddresses() {
|
||||
const res = await winofyFetch<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
async function getCities() {
|
||||
const res = await winofyFetch<PaginatedResponse<City> | City[]>("/v1/cities/", { auth: false });
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
async function getNeighborhoods() {
|
||||
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
|
||||
auth: false,
|
||||
params: { limit: 200 },
|
||||
});
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
export default async function AddressesPage() {
|
||||
await requireSession("/addresses");
|
||||
const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { getCart } from "@/lib/actions/cart";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import type { Address } from "@/types/api";
|
||||
import { getAddresses } from "@/lib/api/addresses";
|
||||
import { CheckoutForm } from "@/components/customer/checkout-form";
|
||||
|
||||
async function getAddresses() {
|
||||
const res = await winofyFetch<PaginatedResponse<Address> | Address[]>("/v1/addresses/");
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
await requireSession("/checkout");
|
||||
const [cart, addresses] = await Promise.all([getCart(), getAddresses()]);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,17 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { winofyFetch, ApiError } from "@/lib/api/winofy";
|
||||
import { ApiError } from "@/lib/api/winofy";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroup } from "@/lib/api/orders";
|
||||
import { OrderCard } from "@/components/customer/order-card";
|
||||
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
|
||||
async function getOrderGroup(uuid: string) {
|
||||
try {
|
||||
return await winofyFetch<OrderGroup>(`/v1/order-groups/${uuid}/`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) {
|
||||
const { uuid } = await params;
|
||||
await requireSession(`/order-groups/${uuid}`);
|
||||
const orderGroup = await getOrderGroup(uuid);
|
||||
const orderGroup = await getOrderGroup(uuid).catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
});
|
||||
if (!orderGroup) notFound();
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroups } from "@/lib/api/orders";
|
||||
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
|
||||
async function getOrderGroups() {
|
||||
const res = await winofyFetch<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export default async function OrdersPage() {
|
||||
await requireSession("/orders");
|
||||
|
|
|
|||
|
|
@ -1,53 +1,46 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { winofyFetch, ApiError } from "@/lib/api/winofy";
|
||||
import type { ProductDetail } from "@/types/api";
|
||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
||||
|
||||
const UNIT_LABELS_FA: Record<string, string> = { gram: "گرم", ml: "میلیلیتر", piece: "عدد" };
|
||||
|
||||
async function getProduct(uuid: string) {
|
||||
try {
|
||||
return await winofyFetch<ProductDetail>(`/v1/products/${uuid}/`, { auth: false });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
import { ApiError } from "@/lib/api/winofy";
|
||||
import { getProduct } from "@/lib/api/catalog";
|
||||
import { HeroBackButton } from "@/components/ui/hero-back-button";
|
||||
import { ProductPurchasePanel } from "@/components/product/product-purchase-panel";
|
||||
|
||||
export default async function ProductDetailPage({ params }: PageProps<"/products/[uuid]">) {
|
||||
const { uuid } = await params;
|
||||
const product = await getProduct(uuid);
|
||||
const product = await getProduct(uuid).catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
});
|
||||
if (!product) notFound();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
|
||||
<Link href={`/stores/${product.store.uuid}`} className="text-sm text-primary underline underline-offset-4">
|
||||
← بازگشت به {product.store.name}
|
||||
</Link>
|
||||
|
||||
<div className="aspect-square w-full overflow-hidden rounded-2xl bg-muted">
|
||||
<main className="flex min-h-full flex-1 flex-col bg-white pb-[140px]">
|
||||
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
<HeroBackButton href={`/stores/${product.store.uuid}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 px-5 pt-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-xl font-bold">{product.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{product.unit_value} {UNIT_LABELS_FA[product.unit_type] ?? product.unit_type}
|
||||
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
|
||||
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-semibold leading-none text-primary-action">
|
||||
{product.store.name}
|
||||
</Link>
|
||||
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
|
||||
{product.price.toLocaleString("fa-IR")} تومان
|
||||
</p>
|
||||
<p className="text-lg font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
|
||||
{product.description && <p className="text-sm text-muted-foreground">{product.description}</p>}
|
||||
</div>
|
||||
|
||||
<AddToCartButton
|
||||
productUuid={product.uuid}
|
||||
disabled={!product.is_active || product.is_out_of_stock}
|
||||
outOfStock={product.is_out_of_stock}
|
||||
className="w-full"
|
||||
/>
|
||||
<ProductPurchasePanel product={product}>
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-[18px] font-bold leading-none text-foreground">توضیحات محصول</h2>
|
||||
<p className="text-right text-[15px] leading-[1.6] text-gray-600">{product.description}</p>
|
||||
</section>
|
||||
</ProductPurchasePanel>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +1,69 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { winofyFetch, ApiError, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import type { ProductListItem, StoreDetail } from "@/types/api";
|
||||
import { AddToCartButton } from "@/components/customer/add-to-cart-button";
|
||||
import { ApiError } from "@/lib/api/winofy";
|
||||
import { getStore } from "@/lib/api/stores";
|
||||
import { getProducts } from "@/lib/api/catalog";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { HeroBackButton } from "@/components/ui/hero-back-button";
|
||||
import { StoreInfoCard } from "@/components/store/store-info-card";
|
||||
import { StoreSearchBar } from "@/components/store/store-search-bar";
|
||||
import { ProductGridCard } from "@/components/store/product-grid-card";
|
||||
import { StickyCartBar } from "@/components/store/sticky-cart-bar";
|
||||
import type { CartItem } from "@/types/api";
|
||||
|
||||
const WEEKDAY_LABELS_FA = ["شنبه", "یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه"];
|
||||
export default async function StoreDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps<"/stores/[uuid]">) {
|
||||
const { uuid } = await params;
|
||||
const { search: searchRaw } = await searchParams;
|
||||
const search = typeof searchRaw === "string" ? searchRaw : undefined;
|
||||
|
||||
async function getStore(uuid: string) {
|
||||
try {
|
||||
return await winofyFetch<StoreDetail>(`/v1/stores/${uuid}/`, { auth: false });
|
||||
} catch (err) {
|
||||
const store = await getStore(uuid).catch((err) => {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStoreProducts(uuid: string) {
|
||||
const res = await winofyFetch<PaginatedResponse<ProductListItem>>("/v1/products/", {
|
||||
auth: false,
|
||||
params: { store: uuid, limit: 100 },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export default async function StoreDetailPage({ params }: PageProps<"/stores/[uuid]">) {
|
||||
const { uuid } = await params;
|
||||
const store = await getStore(uuid);
|
||||
if (!store) notFound();
|
||||
|
||||
const products = await getStoreProducts(uuid);
|
||||
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
|
||||
const cart = session ? await getCart() : null;
|
||||
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
|
||||
|
||||
const cartItemByProduct = new Map<string, CartItem>();
|
||||
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-border">
|
||||
<div className="h-32 w-full bg-muted">
|
||||
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
|
||||
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
|
||||
{store.cover_image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{store.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={store.logo_url}
|
||||
alt={store.name}
|
||||
className="h-16 w-16 shrink-0 rounded-full border border-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 shrink-0 rounded-full bg-muted" />
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{store.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{store.category.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
⭐ {Number(store.rating_avg).toFixed(1)} ({store.rating_count} نظر) ·{" "}
|
||||
{store.is_open ? "باز" : "بسته"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
|
||||
<HeroBackButton href="/" />
|
||||
</div>
|
||||
|
||||
{store.description && <p className="text-sm text-muted-foreground">{store.description}</p>}
|
||||
<StoreInfoCard store={store} />
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full bg-muted px-3 py-1">
|
||||
حداقل سفارش: {store.min_order_amount.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-3 py-1">
|
||||
هزینه ارسال: {store.delivery_fee.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
{store.free_delivery_threshold != null && (
|
||||
<span className="rounded-full bg-muted px-3 py-1">
|
||||
ارسال رایگان بالای {store.free_delivery_threshold.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{store.accepts_wallet && <span className="rounded-full border border-border px-3 py-1">کیف پول</span>}
|
||||
{store.accepts_online && <span className="rounded-full border border-border px-3 py-1">درگاه بانکی</span>}
|
||||
{store.accepts_cash_on_delivery && (
|
||||
<span className="rounded-full border border-border px-3 py-1">پرداخت در محل</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{store.working_hours?.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer font-medium">ساعات کاری</summary>
|
||||
<ul className="mt-2 flex flex-col gap-1 text-muted-foreground">
|
||||
{store.working_hours.map((wh) => (
|
||||
<li key={wh.weekday} className="flex justify-between">
|
||||
<span>{WEEKDAY_LABELS_FA[wh.weekday]}</span>
|
||||
<span>{wh.is_closed ? "تعطیل" : `${wh.opens_at} تا ${wh.closes_at}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">محصولات</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<article key={product.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-3">
|
||||
<Link href={`/products/${product.uuid}`} className="flex flex-col gap-2">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-xl bg-muted">
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="line-clamp-2 text-sm font-medium">{product.name}</h3>
|
||||
<p className="text-sm font-semibold">{product.price.toLocaleString("fa-IR")} تومان</p>
|
||||
</Link>
|
||||
<AddToCartButton
|
||||
productUuid={product.uuid}
|
||||
disabled={!product.is_active || product.is_out_of_stock}
|
||||
outOfStock={product.is_out_of_stock}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
{products.length === 0 && <p className="text-sm text-muted-foreground">محصولی یافت نشد.</p>}
|
||||
</div>
|
||||
<section className="px-4 pt-2">
|
||||
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
|
||||
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
|
||||
</section>
|
||||
|
||||
<div className="pt-4">
|
||||
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
|
||||
{products.map((product) => (
|
||||
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,30 @@
|
|||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { MapPinOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { setLocation } from "@/lib/location/client";
|
||||
|
||||
// Matches the standard GeolocationPositionError codes.
|
||||
const REASON_MESSAGES: Record<string, { title: string; body: string }> = {
|
||||
"1": {
|
||||
title: "دسترسی به موقعیت مکانی رد شد",
|
||||
body: "برای استفاده از این قابلیت، دسترسی به موقعیت مکانی را برای این سایت از تنظیمات مرورگر یا سیستمعامل خود فعال کنید.",
|
||||
},
|
||||
"2": {
|
||||
title: "موقعیت مکانی شناسایی نشد",
|
||||
body: "سیستمعامل شما نتوانست موقعیت فعلی را تشخیص دهد — معمولاً به این دلیل که سرویس موقعیتیابی (Location Services) خاموش است یا اتصال Wi-Fi برقرار نیست. لطفاً این موارد را بررسی کنید یا محله خود را دستی انتخاب کنید.",
|
||||
},
|
||||
"3": {
|
||||
title: "زمان شناسایی موقعیت به پایان رسید",
|
||||
body: "دریافت موقعیت مکانی بیش از حد طول کشید. لطفاً دوباره تلاش کنید یا محله خود را دستی انتخاب کنید.",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_REASON = {
|
||||
title: "موقعیت مکانی شناسایی نشد",
|
||||
body: "مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.",
|
||||
};
|
||||
|
||||
// L04 — winofyfrontenddoc (1).md §4.2. CTA hierarchy deliberately flips from L01:
|
||||
// manual selection is primary here (most likely to succeed), retry is secondary.
|
||||
export default function LocationErrorPage() {
|
||||
|
|
@ -20,8 +40,11 @@ function LocationErrorPageInner() {
|
|||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const next = searchParams.get("next") ?? "/";
|
||||
const reason = searchParams.get("reason");
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
const { title, body } = (reason ? REASON_MESSAGES[reason] : undefined) ?? DEFAULT_REASON;
|
||||
|
||||
function retry() {
|
||||
setRetrying(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
|
|
@ -30,23 +53,26 @@ function LocationErrorPageInner() {
|
|||
router.push(next);
|
||||
router.refresh();
|
||||
},
|
||||
() => setRetrying(false),
|
||||
(error) => {
|
||||
setRetrying(false);
|
||||
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
|
||||
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-error/10 text-error">
|
||||
<MapPinOff className="h-14 w-14" />
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-headline-medium font-extrabold text-foreground">موقعیت مکانی شناسایی نشد</h1>
|
||||
<p className="text-body-large text-gray-700">مشکلی در دریافت موقعیت شما پیش آمد. لطفاً محله خود را دستی انتخاب کنید.</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/satelliet.png" alt="" className="w-auto h-full my-4" width={390} height={296}/>
|
||||
|
||||
<div className="flex flex-col gap-6 my-4">
|
||||
<h1 className="text-headline-medium font-extrabold text-foreground">{title}</h1>
|
||||
<p className="text-body-large text-gray-700 leading-6">{body}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full flex-col gap-4">
|
||||
<div className="mt-[50%] flex w-full flex-col gap-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
|
||||
|
|
@ -57,7 +83,7 @@ function LocationErrorPageInner() {
|
|||
<button type="button" onClick={retry} disabled={retrying} className="text-body-medium font-semibold text-primary-action">
|
||||
{retrying ? "در حال تلاش..." : "تلاش مجدد"}
|
||||
</button>
|
||||
<span className="text-body-medium text-gray-500">پشتیبانی</span>
|
||||
<span className="text-body-medium text-primary-action mt-8">پشتیبانی</span>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ const BENEFITS = [
|
|||
{ icon: Store, text: "کشف بازارچههای اطراف" },
|
||||
];
|
||||
|
||||
// L01 — winofyfrontenddoc (1).md §4.1. No illustration asset was provided;
|
||||
// approximated with a simple icon badge rather than the isometric map graphic.
|
||||
// L01 — winofyfrontenddoc (1).md §4.1.
|
||||
export default function LocationPermissionPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
|
|
@ -32,39 +31,50 @@ function LocationPermissionPageInner() {
|
|||
setLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLocation({ mode: "geo", lat: position.coords.latitude, lng: position.coords.longitude });
|
||||
setLocation({
|
||||
mode: "geo",
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
});
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
},
|
||||
() => {
|
||||
(error) => {
|
||||
setLoading(false);
|
||||
router.push(`/location/error?next=${encodeURIComponent(next)}`);
|
||||
router.push(`/location/error?next=${encodeURIComponent(next)}&reason=${error.code}`);
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
|
||||
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-primary-action/10 text-primary-action">
|
||||
<MapPin className="h-14 w-14" />
|
||||
</span>
|
||||
<main className="flex min-h-full flex-1 flex-col bg-surface">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/Rectangle.png" alt="" className="h-auto w-full" width={390} height={296} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-headline-medium font-extrabold text-foreground">بازارچه محلی شما</h1>
|
||||
<p className="text-body-large text-gray-700">برای نمایش فروشگاههای نزدیک، به موقعیت مکانی شما نیاز داریم</p>
|
||||
<div className="flex flex-1 flex-col items-center gap-8 px-6 py-10 text-start">
|
||||
<div className="flex flex-col gap-8">
|
||||
<h1 className="text-headline-medium font-extrabold text-foreground">
|
||||
بازارچه محلی شما
|
||||
</h1>
|
||||
<p className="text-body-large text-gray-700 leading-6">
|
||||
برای نمایش فروشگاههای نزدیک، به موقعیت مکانی شما نیاز داریم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 text-right">
|
||||
{BENEFITS.map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3">
|
||||
<div
|
||||
key={text}
|
||||
className="flex items-center gap-3 mb-3 mr-2"
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0 text-primary-action" />
|
||||
<span className="text-body-medium text-foreground">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full flex-col gap-4">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
|
||||
|
|
@ -75,12 +85,15 @@ function LocationPermissionPageInner() {
|
|||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/location/search?next=${encodeURIComponent(next)}`)}
|
||||
onClick={() =>
|
||||
router.push(`/location/search?next=${encodeURIComponent(next)}`)
|
||||
}
|
||||
className="text-body-medium font-semibold text-primary-action"
|
||||
>
|
||||
انتخاب دستی محله
|
||||
</button>
|
||||
<span className="text-body-medium text-gray-500">پشتیبانی</span>
|
||||
<span className="text-body-medium text-primary-action text-center mt-6">پشتیبانی</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import { Suspense } from "react";
|
||||
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import { getLocationServer } from "@/lib/location/server";
|
||||
import type { Neighborhood } from "@/types/api";
|
||||
import { getNeighborhoods } from "@/lib/api/locations";
|
||||
import { NeighborhoodSearch } from "@/components/location/neighborhood-search";
|
||||
|
||||
async function getNeighborhoods() {
|
||||
const res = await winofyFetch<PaginatedResponse<Neighborhood> | Neighborhood[]>("/v1/neighborhoods/", {
|
||||
auth: false,
|
||||
params: { limit: 200 },
|
||||
});
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
export default async function NeighborhoodSearchPage() {
|
||||
const [neighborhoods, location] = await Promise.all([getNeighborhoods(), getLocationServer()]);
|
||||
const selectedNeighborhoodUuid = location?.mode === "manual" ? location.neighborhoodUuid : null;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Suspense } from "react";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { getLocationServer } from "@/lib/location/server";
|
||||
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
|
||||
import { getCart } from "@/lib/actions/cart";
|
||||
import type { StoreCategory, StoreListItem } from "@/types/api";
|
||||
import { getStoreCategories, getStores } from "@/lib/api/stores";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { SiteHeader } from "@/components/home/site-header";
|
||||
import { SearchBar } from "@/components/home/search-bar";
|
||||
import { LocationReminderBanner } from "@/components/home/location-reminder-banner";
|
||||
|
|
@ -12,21 +11,6 @@ import { StoreCard } from "@/components/home/store-card";
|
|||
import { SellerPromoCard } from "@/components/home/seller-promo-card";
|
||||
import { BottomNav } from "@/components/home/bottom-nav";
|
||||
|
||||
async function getStoreCategories() {
|
||||
const res = await winofyFetch<PaginatedResponse<StoreCategory> | StoreCategory[]>("/v1/store-categories/", {
|
||||
auth: false,
|
||||
});
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
}
|
||||
|
||||
async function getStores(params: { category?: string; search?: string; neighborhood?: string; lat?: number; lng?: number }) {
|
||||
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
|
||||
auth: false,
|
||||
params: { limit: 20, ...params },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export default async function HomePage({ searchParams }: PageProps<"/">) {
|
||||
const params = await searchParams;
|
||||
const category = typeof params.category === "string" ? params.category : undefined;
|
||||
|
|
@ -62,8 +46,8 @@ export default async function HomePage({ searchParams }: PageProps<"/">) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3 pt-4">
|
||||
<h2 className="px-4 text-title-large font-bold text-foreground">فروشگاههای نزدیک</h2>
|
||||
<section className="flex flex-col gap-4 pt-4">
|
||||
<h2 className="px-4 text-2xl font-extrabold text-foreground">فروشگاههای نزدیک</h2>
|
||||
<Suspense fallback={null}>
|
||||
<CategoryChips categories={categories} />
|
||||
</Suspense>
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -18,13 +18,15 @@ export function CategoryChips({ categories }: { categories: StoreCategory[] }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-1">
|
||||
<div className="flex gap-2 overflow-x-auto px-4 min-h-full my-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(null)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-2 text-body-medium",
|
||||
!activeUuid ? "bg-primary-action text-white" : "border border-gray-300 bg-white text-foreground",
|
||||
"shrink-0 rounded-full px-4 py-2 mb-4 text-body-medium font-medium flex items-center",
|
||||
!activeUuid
|
||||
? "bg-primary-action text-white"
|
||||
: "border border-gray-300 bg-white text-foreground",
|
||||
)}
|
||||
>
|
||||
همه
|
||||
|
|
@ -35,7 +37,7 @@ export function CategoryChips({ categories }: { categories: StoreCategory[] }) {
|
|||
type="button"
|
||||
onClick={() => select(category.uuid)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-2 text-body-medium",
|
||||
"shrink-0 rounded-full px-3 py-2 text-body-medium font-medium flex items-center mb-4",
|
||||
activeUuid === category.uuid
|
||||
? "bg-primary-action text-white"
|
||||
: "border border-gray-300 bg-white text-foreground",
|
||||
|
|
|
|||
|
|
@ -1,26 +1,21 @@
|
|||
import Link from "next/link";
|
||||
import type { StoreListItem } from "@/types/api";
|
||||
|
||||
/** winofyfrontenddoc (1).md §3.5 store-card. */
|
||||
export function StoreCard({ store }: { store: StoreListItem }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/stores/${store.uuid}`}
|
||||
className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3 shadow-sm"
|
||||
className="flex items-center gap-4 rounded-2xl border border-gray-300 bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-[10px] border border-gray-300 bg-muted">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-muted">
|
||||
{store.logo_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={store.logo_url} alt={store.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<h3 className="text-[15px] leading-[22px] font-bold text-foreground">{store.name}</h3>
|
||||
<span className="w-fit rounded-full bg-muted px-2 py-0.5 text-label text-gray-600">{store.category.name}</span>
|
||||
<div className="flex items-center justify-between text-label text-gray-600">
|
||||
<span>⭐ {Number(store.rating_avg).toFixed(1)}</span>
|
||||
<span>{store.is_open ? "باز" : "بسته"}</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<h3 className="text-[18px] font-bold leading-6 text-foreground">{store.name}</h3>
|
||||
<span className="w-fit rounded-full bg-gray-100 px-3 py-1 text-label text-gray-600">{store.category.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowRight, Check, MapPin, Search, SearchX, X } from "lucide-react";
|
||||
import { setLocation, clearLocation } from "@/lib/location/client";
|
||||
|
|
@ -21,14 +21,46 @@ export function NeighborhoodSearch({
|
|||
|
||||
const [query, setQuery] = useState("");
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [results, setResults] = useState<Neighborhood[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.trim();
|
||||
return neighborhoods.filter((n) => n.name.includes(q));
|
||||
}, [neighborhoods, query]);
|
||||
// Real, visible fetch to our own /api/locations/neighborhoods route (debounced)
|
||||
// — not just filtering the initial list in memory.
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
// Nothing to reset here — the render below only reads `results`/`searching`
|
||||
// once `query.trim()` is non-empty, so an empty query needs no state change.
|
||||
if (!trimmed) return;
|
||||
|
||||
const state: "idle" | "results" | "empty" = !query.trim() ? "idle" : results.length > 0 ? "results" : "empty";
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await fetch(`/api/locations/neighborhoods?search=${encodeURIComponent(trimmed)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data: Neighborhood[] = await res.json();
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
if (!(err instanceof DOMException && err.name === "AbortError")) setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
const state: "idle" | "searching" | "results" | "empty" = !query.trim()
|
||||
? "idle"
|
||||
: searching
|
||||
? "searching"
|
||||
: results.length > 0
|
||||
? "results"
|
||||
: "empty";
|
||||
const suggestions = neighborhoods.slice(0, 8);
|
||||
|
||||
function selectNeighborhood(n: Neighborhood) {
|
||||
|
|
@ -55,9 +87,9 @@ export function NeighborhoodSearch({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => selectNeighborhood(n)}
|
||||
className="flex w-full items-center gap-3 border-b border-gray-300 py-4 text-right"
|
||||
className="flex max-w-full items-center gap-3 border-t border-gray-300 py-4 text-right mx-4"
|
||||
>
|
||||
<MapPin className={isSelected ? "h-5 w-5 shrink-0 text-primary-action" : "h-5 w-5 shrink-0 text-gray-500"} />
|
||||
<MapPin className={isSelected ? "h-5 w-5 shrink-0 text-primary-action" : "h-5 w-5 shrink-0 text-gray-700"} />
|
||||
<span className={isSelected ? "flex-1 text-body-large font-semibold text-primary-action" : "flex-1 text-body-large text-foreground"}>
|
||||
{n.city.name}، {n.name}
|
||||
</span>
|
||||
|
|
@ -67,12 +99,12 @@ export function NeighborhoodSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col bg-surface px-4 py-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<main className="flex min-h-full flex-1 flex-col bg-surface px-4 py-12">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<button type="button" onClick={() => router.back()} aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</button>
|
||||
<h1 className="text-title-large font-extrabold text-foreground">انتخاب محله</h1>
|
||||
<h1 className="text-title-large font-extrabold text-foreground mr-24">انتخاب محله</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -82,15 +114,15 @@ export function NeighborhoodSearch({
|
|||
: "flex h-12 items-center gap-2 rounded-3xl border border-gray-300 bg-white px-4"
|
||||
}
|
||||
>
|
||||
<Search className="h-5 w-5 shrink-0 text-gray-500" />
|
||||
<Search className="h-5 w-5 shrink-0 text-primary-action" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder="با نام محله خود را جستجو کنید"
|
||||
className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-500 focus:outline-none"
|
||||
placeholder="شهر یا محله خود را جستجو کنید"
|
||||
className="h-full flex-1 bg-transparent text-body-medium text-foreground placeholder:text-gray-600 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -106,17 +138,21 @@ export function NeighborhoodSearch({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{state === "searching" && (
|
||||
<p className="py-8 text-center text-body-medium text-gray-500">در حال جستجو...</p>
|
||||
)}
|
||||
|
||||
{state === "empty" && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<SearchX className="h-12 w-12 text-gray-400" />
|
||||
<h2 className="text-title-medium font-semibold text-foreground">محلهای با این نام پیدا نشد</h2>
|
||||
<p className="text-body-medium text-gray-600">نام محله را بررسی کنید یا محله دیگری را جستجو کنید</p>
|
||||
<p className="text-body-medium text-gray-600">نام محله را بررسی کنید یا محله دیگری جستجو کنید</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "results" && (
|
||||
<div className="mt-4 flex flex-col">
|
||||
<h2 className="pb-2 text-label text-gray-600">نتایج جستجو</h2>
|
||||
<div className="m-4 flex flex-col">
|
||||
<h2 className="pb-4 text-label text-gray-600">نتایج جستجو</h2>
|
||||
{results.map((n) => (
|
||||
<NeighborhoodRow key={n.uuid} n={n} />
|
||||
))}
|
||||
|
|
@ -125,7 +161,7 @@ export function NeighborhoodSearch({
|
|||
|
||||
{(state === "idle" || state === "empty") && (
|
||||
<div className="mt-4 flex flex-col">
|
||||
<h2 className="pb-2 text-label text-gray-600">محلههای پیشنهادی</h2>
|
||||
<h2 className="text-label min-w-fit font-bold size-3.5 text-gray-700 mx-4 py-6 flex items-center">محلههای پیشنهادی</h2>
|
||||
{suggestions.map((n) => (
|
||||
<NeighborhoodRow key={n.uuid} n={n} />
|
||||
))}
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Order } from "@/types/api";
|
||||
|
||||
export async function cancelOrder(uuid: string, reason: string = ""): Promise<ActionResult<Order>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { reason } }),
|
||||
);
|
||||
if (result.ok) {
|
||||
revalidatePath(`/orders/${uuid}`);
|
||||
revalidatePath("/orders");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Address, AddressLabel } from "@/types/api";
|
||||
|
||||
export async function getAddresses(): Promise<Address[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<Address>>("/v1/addresses/");
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export interface AddressInput {
|
||||
label: AddressLabel;
|
||||
title: string;
|
||||
|
|
@ -22,9 +27,7 @@ export interface AddressInput {
|
|||
}
|
||||
|
||||
export async function createAddress(input: AddressInput): Promise<ActionResult<Address>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }),
|
||||
);
|
||||
const result = await callApi(() => winofyFetch<Address>("/v1/addresses/", { method: "POST", body: input }));
|
||||
if (result.ok) revalidatePath("/addresses");
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,27 +1,25 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { winofyFetch } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Cart } from "@/types/api";
|
||||
import type { Cart, CartItem } from "@/types/api";
|
||||
|
||||
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult> {
|
||||
export async function getCart(): Promise<Cart> {
|
||||
return winofyFetch<Cart>("/v1/cart/");
|
||||
}
|
||||
|
||||
export async function addToCart(productUuid: string, quantity: number = 1): Promise<ActionResult<CartItem>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<undefined>("/v1/cart/items/", {
|
||||
method: "POST",
|
||||
body: { product_uuid: productUuid, quantity },
|
||||
}),
|
||||
winofyFetch<CartItem>("/v1/cart/items/", { method: "POST", body: { product_uuid: productUuid, quantity } }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise<ActionResult> {
|
||||
export async function updateCartItemQuantity(itemUuid: string, quantity: number): Promise<ActionResult<CartItem>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<undefined>(`/v1/cart/items/${itemUuid}/`, {
|
||||
method: "PATCH",
|
||||
body: { quantity },
|
||||
}),
|
||||
winofyFetch<CartItem>(`/v1/cart/items/${itemUuid}/`, { method: "PATCH", body: { quantity } }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
|
|
@ -38,7 +36,3 @@ export async function clearCart(): Promise<ActionResult> {
|
|||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCart(): Promise<Cart> {
|
||||
return winofyFetch<Cart>("/v1/cart/");
|
||||
}
|
||||
30
src/lib/api/catalog.ts
Normal file
30
src/lib/api/catalog.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import "server-only";
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import type { ProductCategory, ProductDetail, ProductListItem } from "@/types/api";
|
||||
|
||||
export async function getProductCategories(): Promise<ProductCategory[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<ProductCategory>>("/v1/product-categories/", {
|
||||
auth: false,
|
||||
params: { limit: 100 },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export interface GetProductsParams {
|
||||
store?: string;
|
||||
category?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getProducts(params: GetProductsParams = {}): Promise<ProductListItem[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<ProductListItem>>("/v1/products/", {
|
||||
auth: false,
|
||||
params: { limit: 100, ...params },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getProduct(uuid: string): Promise<ProductDetail> {
|
||||
return winofyFetch<ProductDetail>(`/v1/products/${uuid}/`, { auth: false });
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use server";
|
||||
|
||||
import { winofyFetch } from "@/lib/api/winofy";
|
||||
import { winofyFetch } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api";
|
||||
|
||||
62
src/lib/api/dev-logger.ts
Normal file
62
src/lib/api/dev-logger.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import "server-only";
|
||||
|
||||
const DEBUG = process.env.NODE_ENV !== "production";
|
||||
const MAX_ARRAY_ITEMS = 3;
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const RED = "\x1b[31m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
|
||||
function statusColor(status: number): string {
|
||||
if (status >= 500) return RED;
|
||||
if (status >= 400) return YELLOW;
|
||||
return GREEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps big paginated lists (e.g. a 19-store `results` array) from flooding the
|
||||
* terminal — shows the first few items of any array plus a count, recursively.
|
||||
*/
|
||||
function truncateForLog(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const shown = value.slice(0, MAX_ARRAY_ITEMS).map(truncateForLog);
|
||||
if (value.length > MAX_ARRAY_ITEMS) shown.push(`… ${value.length - MAX_ARRAY_ITEMS} more item(s)`);
|
||||
return shown;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, truncateForLog(v)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return JSON.stringify(truncateForLog(value), null, 2);
|
||||
}
|
||||
|
||||
function indent(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
|
||||
* tab for server-side calls, since they run on the Next.js server and the browser
|
||||
* never sees them (no CORS on the Winofy API). Never runs in production.
|
||||
*/
|
||||
export function logWinofyRequest(method: string, url: string, body: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
|
||||
}
|
||||
|
||||
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
|
||||
}
|
||||
36
src/lib/api/locations.ts
Normal file
36
src/lib/api/locations.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import "server-only";
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import type { City, Neighborhood } from "@/types/api";
|
||||
|
||||
export async function getCities(): Promise<City[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<City>>("/v1/cities/", { auth: false, params: { limit: 100 } });
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getCity(uuid: string): Promise<City> {
|
||||
return winofyFetch<City>(`/v1/cities/${uuid}/`, { auth: false });
|
||||
}
|
||||
|
||||
export interface GetNeighborhoodsParams {
|
||||
city?: string;
|
||||
/**
|
||||
* Forwarded to the API for forward-compatibility, but confirmed against
|
||||
* staging (2026-08-22) to currently have no effect — the endpoint returns
|
||||
* the same unfiltered results with or without it. Callers must still filter
|
||||
* client-side (see NeighborhoodSearch) until the backend actually supports it.
|
||||
*/
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getNeighborhoods(params: GetNeighborhoodsParams = {}): Promise<Neighborhood[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<Neighborhood>>("/v1/neighborhoods/", {
|
||||
auth: false,
|
||||
params: { limit: 200, ...params },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getNeighborhood(uuid: string): Promise<Neighborhood> {
|
||||
return winofyFetch<Neighborhood>(`/v1/neighborhoods/${uuid}/`, { auth: false });
|
||||
}
|
||||
22
src/lib/api/media.ts
Normal file
22
src/lib/api/media.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use server";
|
||||
|
||||
import { winofyFetch } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
|
||||
export interface MediaPresignResult {
|
||||
upload_url: string;
|
||||
object_key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the upload flow (§2 of the API guide): get a presigned MinIO PUT
|
||||
* URL. Step 2 — PUT the raw file bytes to `upload_url` directly (client-side,
|
||||
* no auth header, this is a MinIO URL not a Winofy one) — happens in the
|
||||
* browser, not here. Step 3 — send the returned `object_key` as the value of
|
||||
* whatever field you're setting (Store.logo, Product.image, etc.).
|
||||
*/
|
||||
export async function presignUpload(filename: string): Promise<ActionResult<MediaPresignResult>> {
|
||||
return callApi(() =>
|
||||
winofyFetch<MediaPresignResult>("/media/presign/", { method: "POST", body: { filename } }),
|
||||
);
|
||||
}
|
||||
31
src/lib/api/notifications.ts
Normal file
31
src/lib/api/notifications.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Notification } from "@/types/api";
|
||||
|
||||
export async function getNotifications(): Promise<Notification[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<Notification>>("/v1/notifications/");
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getNotification(uuid: string): Promise<Notification> {
|
||||
return winofyFetch<Notification>(`/v1/notifications/${uuid}/`);
|
||||
}
|
||||
|
||||
export async function markNotificationRead(uuid: string): Promise<ActionResult<Notification>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Notification>(`/v1/notifications/${uuid}/mark-read/`, { method: "POST", body: {} }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/notifications");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<ActionResult> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<undefined>("/v1/notifications/mark-all-read/", { method: "POST", body: {} }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/notifications");
|
||||
return result;
|
||||
}
|
||||
28
src/lib/api/orders.ts
Normal file
28
src/lib/api/orders.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Order, OrderGroup } from "@/types/api";
|
||||
|
||||
export async function getOrderGroups(): Promise<OrderGroup[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<OrderGroup>>("/v1/order-groups/");
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getOrderGroup(uuid: string): Promise<OrderGroup> {
|
||||
return winofyFetch<OrderGroup>(`/v1/order-groups/${uuid}/`);
|
||||
}
|
||||
|
||||
/** Single-order tracking — one store's slice of an OrderGroup. */
|
||||
export async function getOrder(uuid: string): Promise<Order> {
|
||||
return winofyFetch<Order>(`/v1/orders/${uuid}/`);
|
||||
}
|
||||
|
||||
export async function cancelOrder(uuid: string, cancelReason: string = ""): Promise<ActionResult<Order>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Order>(`/v1/orders/${uuid}/cancel/`, { method: "POST", body: { cancel_reason: cancelReason } }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/orders");
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import "server-only";
|
||||
import { ApiError } from "@/lib/api/winofy";
|
||||
import { ApiError } from "./winofy";
|
||||
|
||||
export type ActionResult<T = undefined> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; status: number; message: string; fieldErrors: Record<string, string[]> | null };
|
||||
|
||||
/** Wraps a winofyFetch call for use in a Server Action — never throws, always returns a typed result. */
|
||||
export async function callApi<T>(fn: () => Promise<T>): Promise<ActionResult<T>> {
|
||||
try {
|
||||
const data = await fn();
|
||||
23
src/lib/api/reviews.ts
Normal file
23
src/lib/api/reviews.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use server";
|
||||
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { Review } from "@/types/api";
|
||||
|
||||
export async function getReviews(params: { store?: string; limit?: number } = {}): Promise<Review[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<Review>>("/v1/reviews/", {
|
||||
auth: false,
|
||||
params: { limit: 50, ...params },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export interface CreateReviewInput {
|
||||
order_uuid: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export async function createReview(input: CreateReviewInput): Promise<ActionResult<Review>> {
|
||||
return callApi(() => winofyFetch<Review>("/v1/reviews/", { method: "POST", body: input }));
|
||||
}
|
||||
8
src/lib/api/seller/analytics.ts
Normal file
8
src/lib/api/seller/analytics.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import "server-only";
|
||||
import { winofyFetch } from "../winofy";
|
||||
import type { SellerAnalytics } from "@/types/api";
|
||||
|
||||
/** No date-range params are documented — the API returns one fixed reporting window. */
|
||||
export async function getSellerAnalytics(): Promise<SellerAnalytics> {
|
||||
return winofyFetch<SellerAnalytics>("/v1/seller/analytics/");
|
||||
}
|
||||
60
src/lib/api/seller/orders.ts
Normal file
60
src/lib/api/seller/orders.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch, type PaginatedResponse } from "../winofy";
|
||||
import { callApi, type ActionResult } from "../result";
|
||||
import type { Order, OrderStatus } from "@/types/api";
|
||||
|
||||
export async function getSellerOrders(status?: OrderStatus): Promise<Order[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<Order>>("/v1/seller/orders/", {
|
||||
params: { limit: 100, status },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getSellerOrder(uuid: string): Promise<Order> {
|
||||
return winofyFetch<Order>(`/v1/seller/orders/${uuid}/`);
|
||||
}
|
||||
|
||||
async function sellerOrderAction(
|
||||
uuid: string,
|
||||
action: string,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<ActionResult<Order>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Order>(`/v1/seller/orders/${uuid}/${action}/`, { method: "POST", body }),
|
||||
);
|
||||
if (result.ok) {
|
||||
revalidatePath("/seller/orders");
|
||||
revalidatePath(`/seller/orders/${uuid}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function cancelSellerOrder(uuid: string, cancelReason: string = ""): Promise<ActionResult<Order>> {
|
||||
return sellerOrderAction(uuid, "cancel", { cancel_reason: cancelReason });
|
||||
}
|
||||
|
||||
/** placed → preparing */
|
||||
export async function confirmSellerOrder(uuid: string): Promise<ActionResult<Order>> {
|
||||
return sellerOrderAction(uuid, "confirm");
|
||||
}
|
||||
|
||||
/** preparing → ready_to_ship */
|
||||
export async function markSellerOrderReady(uuid: string): Promise<ActionResult<Order>> {
|
||||
return sellerOrderAction(uuid, "mark-ready");
|
||||
}
|
||||
|
||||
/** ready_to_ship → handed_to_courier */
|
||||
export async function markSellerOrderShipped(
|
||||
uuid: string,
|
||||
courierName: string,
|
||||
courierPhone: string,
|
||||
): Promise<ActionResult<Order>> {
|
||||
return sellerOrderAction(uuid, "mark-shipped", { courier_name: courierName, courier_phone: courierPhone });
|
||||
}
|
||||
|
||||
/** handed_to_courier → delivered */
|
||||
export async function markSellerOrderDelivered(uuid: string): Promise<ActionResult<Order>> {
|
||||
return sellerOrderAction(uuid, "mark-delivered");
|
||||
}
|
||||
65
src/lib/api/seller/products.ts
Normal file
65
src/lib/api/seller/products.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch, type PaginatedResponse } from "../winofy";
|
||||
import { callApi, type ActionResult } from "../result";
|
||||
import type { SellerProduct } from "@/types/api";
|
||||
|
||||
export async function getSellerProducts(): Promise<SellerProduct[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<SellerProduct>>("/v1/seller/products/", { params: { limit: 100 } });
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getSellerProduct(uuid: string): Promise<SellerProduct> {
|
||||
return winofyFetch<SellerProduct>(`/v1/seller/products/${uuid}/`);
|
||||
}
|
||||
|
||||
export interface SellerProductInput {
|
||||
name: string;
|
||||
description: string;
|
||||
image?: string | null;
|
||||
category_uuid: string;
|
||||
price: number;
|
||||
unit_type: SellerProduct["unit_type"];
|
||||
unit_value: number;
|
||||
stock_quantity: number;
|
||||
low_stock_threshold: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function createSellerProduct(input: SellerProductInput): Promise<ActionResult<SellerProduct>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<SellerProduct>("/v1/seller/products/", { method: "POST", body: input }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/seller/products");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateSellerProduct(
|
||||
uuid: string,
|
||||
input: Partial<SellerProductInput>,
|
||||
): Promise<ActionResult<SellerProduct>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<SellerProduct>(`/v1/seller/products/${uuid}/`, { method: "PATCH", body: input }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/seller/products");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteSellerProduct(uuid: string): Promise<ActionResult> {
|
||||
const result = await callApi(() => winofyFetch<undefined>(`/v1/seller/products/${uuid}/`, { method: "DELETE" }));
|
||||
if (result.ok) revalidatePath("/seller/products");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Focused inventory-only update — returns 204, no body, unlike the general PATCH above. */
|
||||
export async function updateSellerProductStock(
|
||||
uuid: string,
|
||||
input: { stock_quantity: number; low_stock_threshold?: number },
|
||||
): Promise<ActionResult> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<undefined>(`/v1/seller/products/${uuid}/stock/`, { method: "PATCH", body: input }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/seller/products");
|
||||
return result;
|
||||
}
|
||||
26
src/lib/api/seller/reviews.ts
Normal file
26
src/lib/api/seller/reviews.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch, type PaginatedResponse } from "../winofy";
|
||||
import { callApi, type ActionResult } from "../result";
|
||||
import type { Review } from "@/types/api";
|
||||
|
||||
export async function getSellerReviews(): Promise<Review[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<Review>>("/v1/seller/reviews/", { params: { limit: 100 } });
|
||||
return res.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The spec documents this action's request body as the full customer-facing
|
||||
* review-write shape (order_uuid/rating/comment), which doesn't make sense for
|
||||
* a reply. Sending `seller_reply` here as the best guess (matches the response
|
||||
* field name) — confirm the real field name with backend (or from a 400's
|
||||
* field-error keys) before relying on this.
|
||||
*/
|
||||
export async function replySellerReview(uuid: string, replyText: string): Promise<ActionResult<Review>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<Review>(`/v1/seller/reviews/${uuid}/reply/`, { method: "POST", body: { seller_reply: replyText } }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/seller/reviews");
|
||||
return result;
|
||||
}
|
||||
65
src/lib/api/seller/store.ts
Normal file
65
src/lib/api/seller/store.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch, ApiError } from "../winofy";
|
||||
import { callApi, type ActionResult } from "../result";
|
||||
import type { SellerStore, WorkingHours } from "@/types/api";
|
||||
|
||||
/** `null` means the seller hasn't created a store yet (404) — not an error state to surface. */
|
||||
export async function getSellerStore(): Promise<SellerStore | null> {
|
||||
try {
|
||||
return await winofyFetch<SellerStore>("/v1/seller/store/");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SellerStoreInput {
|
||||
name: string;
|
||||
category_uuid: string;
|
||||
description: string;
|
||||
logo?: string | null;
|
||||
cover_image?: string | null;
|
||||
phone_number: string;
|
||||
city_uuid: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
service_neighborhood_uuids: string[];
|
||||
delivery_radius_km: string;
|
||||
min_order_amount: number;
|
||||
delivery_fee: number;
|
||||
free_delivery_threshold?: number | null;
|
||||
accepts_wallet: boolean;
|
||||
accepts_online: boolean;
|
||||
accepts_cash_on_delivery: boolean;
|
||||
is_open: boolean;
|
||||
}
|
||||
|
||||
export async function createSellerStore(input: SellerStoreInput): Promise<ActionResult<SellerStore>> {
|
||||
const result = await callApi(() => winofyFetch<SellerStore>("/v1/seller/store/", { method: "POST", body: input }));
|
||||
if (result.ok) revalidatePath("/seller");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateSellerStore(input: Partial<SellerStoreInput>): Promise<ActionResult<SellerStore>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<SellerStore>("/v1/seller/store/", { method: "PATCH", body: input }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/seller");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getSellerWorkingHours(): Promise<WorkingHours[]> {
|
||||
return winofyFetch<WorkingHours[]>("/v1/seller/store/working-hours/");
|
||||
}
|
||||
|
||||
/** Full replace — send all 7 days (weekday 0–6) in one call. */
|
||||
export async function setSellerWorkingHours(hours: WorkingHours[]): Promise<ActionResult<WorkingHours[]>> {
|
||||
const result = await callApi(() =>
|
||||
winofyFetch<WorkingHours[]>("/v1/seller/store/working-hours/", { method: "PUT", body: hours }),
|
||||
);
|
||||
if (result.ok) revalidatePath("/seller");
|
||||
return result;
|
||||
}
|
||||
32
src/lib/api/stores.ts
Normal file
32
src/lib/api/stores.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import "server-only";
|
||||
import { winofyFetch, type PaginatedResponse } from "./winofy";
|
||||
import type { StoreCategory, StoreDetail, StoreListItem } from "@/types/api";
|
||||
|
||||
export async function getStoreCategories(): Promise<StoreCategory[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<StoreCategory>>("/v1/store-categories/", {
|
||||
auth: false,
|
||||
params: { limit: 100 },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export interface GetStoresParams {
|
||||
category?: string;
|
||||
search?: string;
|
||||
neighborhood?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getStores(params: GetStoresParams = {}): Promise<StoreListItem[]> {
|
||||
const res = await winofyFetch<PaginatedResponse<StoreListItem>>("/v1/stores/", {
|
||||
auth: false,
|
||||
params: { limit: 20, ...params },
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
|
||||
export async function getStore(uuid: string): Promise<StoreDetail> {
|
||||
return winofyFetch<StoreDetail>(`/v1/stores/${uuid}/`, { auth: false });
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import "server-only";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
||||
|
||||
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export interface PaginatedResponse<T> {
|
|||
|
||||
export async function winofyFetch<T>(path: string, options: WinofyFetchOptions = {}): Promise<T> {
|
||||
const { auth = true, params, headers, body, ...rest } = options;
|
||||
const method = rest.method ?? "GET";
|
||||
|
||||
const url = new URL(BASE_URL.replace(/\/$/, "") + path);
|
||||
if (params) {
|
||||
|
|
@ -39,6 +41,8 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
|||
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
|
||||
logWinofyRequest(method, url.toString(), body);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
...rest,
|
||||
headers: finalHeaders,
|
||||
|
|
@ -46,9 +50,13 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
|||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
if (res.status === 204) {
|
||||
logWinofyResponse(method, url.toString(), res.status, null);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
logWinofyResponse(method, url.toString(), res.status, data);
|
||||
|
||||
if (!res.ok) {
|
||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||
|
|
|
|||
149
src/types/api.ts
149
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 `<field>_url` sibling that's a freshly presigned, already-absolute URL.
|
||||
* Always render the `_url` field directly; never resolve the raw key yourself, and
|
||||
* don't cache `_url` past the response — it's re-signed (and can change) on every read.
|
||||
* Media fields (`icon`, `image`, `logo`, `cover_image`) are MinIO object keys,
|
||||
* not URLs — every one has a `<field>_url` sibling that's a freshly presigned,
|
||||
* already-absolute URL. Always render the `_url` field directly; never resolve
|
||||
* the raw key yourself, and don't cache `_url` past the response — it's
|
||||
* re-signed (and can change) on every read.
|
||||
*/
|
||||
|
||||
// ---- Locations --------------------------------------------------------------
|
||||
|
||||
export interface City {
|
||||
uuid: string;
|
||||
name: string;
|
||||
|
|
@ -17,10 +21,13 @@ export interface City {
|
|||
export interface Neighborhood {
|
||||
uuid: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
/** Nested object, confirmed against staging — not a bare uuid string. */
|
||||
city: City;
|
||||
}
|
||||
|
||||
// ---- Stores / Catalog ---------------------------------------------------
|
||||
|
||||
export interface StoreCategory {
|
||||
uuid: string;
|
||||
name: string;
|
||||
|
|
@ -46,11 +53,10 @@ export interface StoreListItem {
|
|||
is_open: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface WorkingHours {
|
||||
weekday: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Saturday .. 6 = Friday
|
||||
opens_at: string;
|
||||
closes_at: string;
|
||||
opens_at: string | null;
|
||||
closes_at: string | null;
|
||||
is_closed: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +98,8 @@ export interface ProductDetail extends ProductListItem {
|
|||
store: StoreListItem;
|
||||
}
|
||||
|
||||
// ---- Addresses ----------------------------------------------------------
|
||||
|
||||
export type AddressLabel = "home" | "work" | "other";
|
||||
|
||||
export interface Address {
|
||||
|
|
@ -112,6 +120,8 @@ export interface Address {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Cart -----------------------------------------------------------------
|
||||
|
||||
export interface CartItem {
|
||||
uuid: string;
|
||||
product: ProductListItem;
|
||||
|
|
@ -133,20 +143,16 @@ export interface Cart {
|
|||
grand_total: number;
|
||||
}
|
||||
|
||||
// ---- Checkout / Orders ---------------------------------------------------
|
||||
|
||||
export type DeliveryType = "express" | "scheduled";
|
||||
export type PaymentMethod = "wallet" | "online" | "cash_on_delivery";
|
||||
export type PaymentStatus = "pending" | "paid" | "failed";
|
||||
export type OrderStatus =
|
||||
| "placed"
|
||||
| "preparing"
|
||||
| "ready_to_ship"
|
||||
| "handed_to_courier"
|
||||
| "delivered"
|
||||
| "cancelled";
|
||||
export type OrderStatus = "placed" | "preparing" | "ready_to_ship" | "handed_to_courier" | "delivered" | "cancelled";
|
||||
|
||||
export interface OrderItem {
|
||||
uuid: string;
|
||||
product: string;
|
||||
product: string | null;
|
||||
product_name_snapshot: string;
|
||||
unit_price_snapshot: number;
|
||||
quantity: number;
|
||||
|
|
@ -195,3 +201,112 @@ export interface OrderGroup {
|
|||
/** `feature/payment` only. */
|
||||
payment?: { method: PaymentMethod; status: string; payment_url?: string };
|
||||
}
|
||||
|
||||
// ---- Reviews --------------------------------------------------------------
|
||||
|
||||
export interface Review {
|
||||
uuid: string;
|
||||
store: string;
|
||||
product: string | null;
|
||||
rating: number;
|
||||
comment: string;
|
||||
seller_reply: string | null;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Notifications --------------------------------------------------------
|
||||
|
||||
export type NotificationType = "new_order" | "settlement_done" | "new_review" | "order_cancelled";
|
||||
|
||||
export interface Notification {
|
||||
uuid: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
related_order: string | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Seller · Store ---------------------------------------------------
|
||||
|
||||
export type SellerStoreStatus = "pending" | "approved" | "suspended";
|
||||
|
||||
export interface StoreBankAccount {
|
||||
iban: string;
|
||||
account_holder_name: string;
|
||||
}
|
||||
|
||||
export interface SellerStore {
|
||||
uuid: string;
|
||||
name: string;
|
||||
category: StoreCategory;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
logo_url: string | null;
|
||||
cover_image: string | null;
|
||||
cover_image_url: string | null;
|
||||
phone_number: string;
|
||||
city: City;
|
||||
address: string;
|
||||
service_neighborhoods: Neighborhood[];
|
||||
/** DRF decimal-as-string, e.g. "5.00". */
|
||||
delivery_radius_km: string;
|
||||
min_order_amount: number;
|
||||
delivery_fee: number;
|
||||
free_delivery_threshold: number | null;
|
||||
accepts_wallet: boolean;
|
||||
accepts_online: boolean;
|
||||
accepts_cash_on_delivery: boolean;
|
||||
rating_avg: string;
|
||||
rating_count: number;
|
||||
status: SellerStoreStatus;
|
||||
is_open: boolean;
|
||||
bank_account: StoreBankAccount | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Seller · Products ---------------------------------------------------
|
||||
|
||||
export interface SellerProduct {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string | null;
|
||||
image_url: string | null;
|
||||
category: ProductCategory;
|
||||
price: number;
|
||||
unit_type: UnitType;
|
||||
unit_value: number;
|
||||
stock_quantity: number;
|
||||
low_stock_threshold: number;
|
||||
is_active: boolean;
|
||||
sold_count: number;
|
||||
is_out_of_stock: boolean;
|
||||
is_low_stock: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Seller · Analytics ---------------------------------------------------
|
||||
|
||||
export interface SalesChartPoint {
|
||||
bucket: string;
|
||||
sales: number;
|
||||
}
|
||||
|
||||
export interface TopProduct {
|
||||
name: string;
|
||||
units_sold: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface SellerAnalytics {
|
||||
period: string;
|
||||
order_count: number;
|
||||
total_sales: number;
|
||||
cancellation_rate: number;
|
||||
average_order_value: number;
|
||||
chart: SalesChartPoint[];
|
||||
top_products: TopProduct[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue