Fix product links, equalize card heights, and switch to IRANYekan
- Resolve product cards from group endpoints (store-specials,
brand-groups) that only return a productId, not a slug, via a new
/products/id/[id] route backed by GET /products/by-id/{id} — clicking
these was previously falling back to a name search instead of the
detail page.
- Derive discount price/percent generically in normalizeProduct so any
source with real old/new prices shows it, even without an explicit
discount field from the API.
- Product cards and the detail page buy-box: show the discount badge
beside the old (struck-through) price, current price alone on its
own line, matching the reference design.
- Equalize product card heights within a carousel row (Swiper's own
CSS doesn't stretch slides to the tallest sibling in slidesPerView:
"auto" + freeMode mode) so the add-to-cart button lines up across
cards regardless of whether a card has a discount line.
- Featured categories: 110px images with no border/radius, matching
the site's other category card treatment.
- Replace Vazirmatn with the client's IRANYekan font files — light
weight for body text, bold weight for headings only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83809815c1
commit
c2552af079
Binary file not shown.
Binary file not shown.
+14
-3
@@ -23,7 +23,8 @@
|
|||||||
--color-red-dark: var(--red-dark);
|
--color-red-dark: var(--red-dark);
|
||||||
--color-red-light: var(--red-light);
|
--color-red-light: var(--red-light);
|
||||||
--color-red-muted: var(--red-muted);
|
--color-red-muted: var(--red-muted);
|
||||||
--font-sans: var(--font-vazirmatn);
|
--font-sans: var(--font-iranyekan);
|
||||||
|
--font-sans-bold: var(--font-iranyekan-bold);
|
||||||
--font-display: var(--font-syne);
|
--font-display: var(--font-syne);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +39,21 @@ html {
|
|||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-vazirmatn), system-ui, sans-serif;
|
font-family: var(--font-iranyekan), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titles use the bold face; everything else stays on the light one. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: var(--font-iranyekan-bold), var(--font-iranyekan), system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-display {
|
.font-display {
|
||||||
font-family: var(--font-syne), var(--font-vazirmatn), sans-serif;
|
font-family: var(--font-syne), var(--font-iranyekan), sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-up {
|
@keyframes fade-up {
|
||||||
|
|||||||
+14
-6
@@ -1,15 +1,23 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Vazirmatn, Syne } from "next/font/google";
|
import { Syne } from "next/font/google";
|
||||||
|
import localFont from "next/font/local";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { TopBar } from "@/components/home/TopBar";
|
import { TopBar } from "@/components/home/TopBar";
|
||||||
import { Header } from "@/components/home/Header";
|
import { Header } from "@/components/home/Header";
|
||||||
import { Footer } from "@/components/home/Footer";
|
import { Footer } from "@/components/home/Footer";
|
||||||
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee";
|
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee";
|
||||||
|
|
||||||
const vazirmatn = Vazirmatn({
|
// Body text — headings use the bold weight instead, see globals.css.
|
||||||
variable: "--font-vazirmatn",
|
const iranyekan = localFont({
|
||||||
subsets: ["arabic", "latin"],
|
variable: "--font-iranyekan",
|
||||||
weight: ["400", "500", "600", "700"],
|
src: "../../public/fonts/iranyekanweblightfanum.woff",
|
||||||
|
weight: "400",
|
||||||
|
});
|
||||||
|
|
||||||
|
const iranyekanBold = localFont({
|
||||||
|
variable: "--font-iranyekan-bold",
|
||||||
|
src: "../../public/fonts/iranyekanwebboldfanum.woff",
|
||||||
|
weight: "700",
|
||||||
});
|
});
|
||||||
|
|
||||||
const syne = Syne({
|
const syne = Syne({
|
||||||
@@ -34,7 +42,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
<html
|
<html
|
||||||
lang="fa"
|
lang="fa"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className={`${vazirmatn.variable} ${syne.variable} h-full antialiased`}
|
className={`${iranyekan.variable} ${iranyekanBold.variable} ${syne.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ export default async function ProductDetailPage({
|
|||||||
}
|
}
|
||||||
variations={variations}
|
variations={variations}
|
||||||
storeVariants={storeVariants}
|
storeVariants={storeVariants}
|
||||||
fallbackPrice={{ minPrice: product.minPrice, maxPrice: product.maxPrice }}
|
fallbackPrice={{
|
||||||
|
minPrice: product.minPrice,
|
||||||
|
maxPrice: product.maxPrice,
|
||||||
|
oldPrice: product.oldPrice,
|
||||||
|
discount: product.discount,
|
||||||
|
}}
|
||||||
inStock={product.inStock}
|
inStock={product.inStock}
|
||||||
contactPhone={contactPhone}
|
contactPhone={contactPhone}
|
||||||
storeName={businessInfo?.nameFa}
|
storeName={businessInfo?.nameFa}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getProductById } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export default async function ProductByIdPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const product = await getProductById(id);
|
||||||
|
|
||||||
|
if (!product?.slug) notFound();
|
||||||
|
|
||||||
|
redirect(`/products/${product.slug}`);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { Swiper, SwiperSlide } from "swiper/react";
|
|||||||
import { FreeMode } from "swiper/modules";
|
import { FreeMode } from "swiper/modules";
|
||||||
import "swiper/css";
|
import "swiper/css";
|
||||||
import "swiper/css/free-mode";
|
import "swiper/css/free-mode";
|
||||||
import { Placeholder } from "@/components/ui/Placeholder";
|
|
||||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
|
import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
|
||||||
@@ -13,9 +12,9 @@ function CategorySlotCard({ image }: { image: StaticImageWithUrl }) {
|
|||||||
const label = image.titleFa ?? image.titleEn ?? "";
|
const label = image.titleFa ?? image.titleEn ?? "";
|
||||||
const content = (
|
const content = (
|
||||||
<div className="group flex h-full flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md">
|
<div className="group flex h-full flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md">
|
||||||
<div className="mb-3 aspect-square w-[72px] overflow-hidden rounded-full border border-border">
|
<div className="mb-3 w-[110px] overflow-hidden">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={image.url} alt={label} className="h-full w-full object-cover" />
|
<img src={image.url} alt={label} className="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
{label && (
|
{label && (
|
||||||
<span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
|
<span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
|
||||||
@@ -87,13 +86,14 @@ export function FeaturedCategories({
|
|||||||
className="animate-slide-in group flex h-full flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
|
className="animate-slide-in group flex h-full flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
|
||||||
style={{ animationDelay: `${i * 60}ms` }}
|
style={{ animationDelay: `${i * 60}ms` }}
|
||||||
>
|
>
|
||||||
<Placeholder
|
<div className="mb-3 w-[110px] overflow-hidden">
|
||||||
src={cat.image ?? "/images/placeholder.svg"}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
alt={cat.name}
|
<img
|
||||||
width="72px"
|
src={cat.image ?? "/images/placeholder.svg"}
|
||||||
height="72px"
|
alt={cat.name}
|
||||||
className="mb-3 rounded-full transition-colors group-hover:border-red"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
|
<span className="w-full truncate text-center text-xs font-medium leading-relaxed md:text-sm">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ export function ProductPurchasePanel({
|
|||||||
header: ReactNode;
|
header: ReactNode;
|
||||||
variations: ProductVariation[];
|
variations: ProductVariation[];
|
||||||
storeVariants: ProductStoreVariant[];
|
storeVariants: ProductStoreVariant[];
|
||||||
fallbackPrice: { minPrice: number | null; maxPrice: number | null };
|
fallbackPrice: {
|
||||||
|
minPrice: number | null;
|
||||||
|
maxPrice: number | null;
|
||||||
|
oldPrice?: number | null;
|
||||||
|
discount?: number;
|
||||||
|
};
|
||||||
inStock: boolean;
|
inStock: boolean;
|
||||||
contactPhone?: string;
|
contactPhone?: string;
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
@@ -68,6 +73,17 @@ export function ProductPurchasePanel({
|
|||||||
? `${formatPrice(fallbackPrice.minPrice)} - ${formatPrice(fallbackPrice.maxPrice)}`
|
? `${formatPrice(fallbackPrice.minPrice)} - ${formatPrice(fallbackPrice.maxPrice)}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const variantOldPrice =
|
||||||
|
matchedVariant?.discountedPrice != null &&
|
||||||
|
matchedVariant?.price != null &&
|
||||||
|
matchedVariant.price !== matchedVariant.discountedPrice
|
||||||
|
? matchedVariant.price
|
||||||
|
: null;
|
||||||
|
const oldPrice = hasVariations ? variantOldPrice : (fallbackPrice.oldPrice ?? null);
|
||||||
|
const discount = hasVariations
|
||||||
|
? (oldPrice != null && price != null ? Math.round((1 - price / oldPrice) * 100) : null)
|
||||||
|
: (fallbackPrice.discount ?? null);
|
||||||
|
|
||||||
const stockQuantity = hasVariations ? (matchedVariant?.stockQuantity ?? 0) : null;
|
const stockQuantity = hasVariations ? (matchedVariant?.stockQuantity ?? 0) : null;
|
||||||
const available = hasVariations ? Boolean(matchedVariant) && stockQuantity! > 0 : inStock;
|
const available = hasVariations ? Boolean(matchedVariant) && stockQuantity! > 0 : inStock;
|
||||||
const maxQty = stockQuantity && stockQuantity > 0 ? stockQuantity : 99;
|
const maxQty = stockQuantity && stockQuantity > 0 ? stockQuantity : 99;
|
||||||
@@ -137,10 +153,22 @@ export function ProductPurchasePanel({
|
|||||||
|
|
||||||
{price != null ? (
|
{price != null ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-5 text-2xl font-bold text-red-dark">
|
<div className="mb-5 space-y-1.5">
|
||||||
{priceRangeLabel ?? formatPrice(price)}
|
{!priceRangeLabel && oldPrice != null && (
|
||||||
<span className="mr-1 text-sm font-normal text-muted">تومان</span>
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||||
|
{discount != null && (
|
||||||
|
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-bold text-green-700">
|
||||||
|
{discount}٪
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-2xl font-bold text-red-dark">
|
||||||
|
{priceRangeLabel ?? formatPrice(price)}
|
||||||
|
<span className="mr-1 text-sm font-normal text-muted">تومان</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-foreground">تعداد:</span>
|
<span className="text-sm font-medium text-foreground">تعداد:</span>
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
|||||||
className="!px-1 !py-2"
|
className="!px-1 !py-2"
|
||||||
>
|
>
|
||||||
{Children.map(children, (child) => (
|
{Children.map(children, (child) => (
|
||||||
<SwiperSlide style={{ width: "auto" }}>{child}</SwiperSlide>
|
// Swiper's own CSS pins slide height to 100%, which — with no definite
|
||||||
|
// wrapper height — resolves per-slide instead of stretching to match
|
||||||
|
// the tallest sibling. height:auto (inline, so it wins) lets normal
|
||||||
|
// flex stretch equalize card heights across a row.
|
||||||
|
<SwiperSlide style={{ width: "auto", height: "auto" }}>{child}</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ export function ProductCard({
|
|||||||
fit="contain"
|
fit="contain"
|
||||||
className="aspect-square w-full rounded-lg border-0"
|
className="aspect-square w-full rounded-lg border-0"
|
||||||
/>
|
/>
|
||||||
{discount && (
|
|
||||||
<span className="absolute top-3 right-3 rounded-md bg-red px-2 py-0.5 text-xs font-bold text-white">
|
|
||||||
{discount}٪
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{variant === "brand" && (
|
{variant === "brand" && (
|
||||||
<span className="font-display absolute top-3 left-3 text-[10px] font-bold tracking-widest text-red-dark uppercase">
|
<span className="font-display absolute top-3 left-3 text-[10px] font-bold tracking-widest text-red-dark uppercase">
|
||||||
{brand}
|
{brand}
|
||||||
@@ -54,8 +49,18 @@ export function ProductCard({
|
|||||||
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="space-y-1">
|
||||||
<span className="text-base font-bold text-red-dark">
|
{oldPrice != null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||||
|
{discount != null && (
|
||||||
|
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-[11px] font-bold text-green-700">
|
||||||
|
{discount}٪
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-base font-bold text-red-dark">
|
||||||
{price != null ? (
|
{price != null ? (
|
||||||
<>
|
<>
|
||||||
{formatPrice(price)}
|
{formatPrice(price)}
|
||||||
@@ -64,12 +69,7 @@ export function ProductCard({
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-normal text-muted">تماس با فروشگاه</span>
|
<span className="text-sm font-normal text-muted">تماس با فروشگاه</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
{oldPrice && (
|
|
||||||
<span className="text-xs text-muted line-through">
|
|
||||||
{formatPrice(oldPrice)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -77,9 +77,15 @@ export function ProductCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`group ${fluid ? "w-full" : "w-[220px] shrink-0 md:w-[240px]"} overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md`}
|
className={`group flex h-full flex-col ${fluid ? "w-full" : "w-[220px] shrink-0 md:w-[240px]"} overflow-hidden rounded-xl border border-border bg-surface transition-shadow hover:shadow-md`}
|
||||||
>
|
>
|
||||||
{href ? <a href={href}>{info}</a> : info}
|
{href ? (
|
||||||
|
<a href={href} className="block flex-1">
|
||||||
|
{info}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1">{info}</div>
|
||||||
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{price != null ? (
|
{price != null ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
+40
-11
@@ -129,12 +129,30 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
|||||||
const firstVariant = variants?.[0];
|
const firstVariant = variants?.[0];
|
||||||
const price =
|
const price =
|
||||||
store?.minPrice ?? raw.discountedPrice ?? raw.price ?? firstVariant?.discountedPrice ?? firstVariant?.price;
|
store?.minPrice ?? raw.discountedPrice ?? raw.price ?? firstVariant?.discountedPrice ?? firstVariant?.price;
|
||||||
const oldPrice = raw.discountedPrice != null && raw.price != null ? raw.price : raw.oldPrice;
|
const oldPrice =
|
||||||
|
raw.discountedPrice != null && raw.price != null
|
||||||
|
? raw.price
|
||||||
|
: firstVariant?.discountedPrice != null && firstVariant?.price != null
|
||||||
|
? firstVariant.price
|
||||||
|
: raw.oldPrice;
|
||||||
|
const numericPrice = price != null ? Number(price) : null;
|
||||||
|
const numericOldPrice = oldPrice != null ? Number(oldPrice) : undefined;
|
||||||
|
// The API doesn't always send a discount percent alongside price/oldPrice —
|
||||||
|
// derive it from the two real numbers rather than leaving the badge blank.
|
||||||
|
const discount =
|
||||||
|
raw.discount != null
|
||||||
|
? Number(raw.discount)
|
||||||
|
: numericOldPrice != null && numericPrice != null && numericOldPrice > numericPrice
|
||||||
|
? Math.round((1 - numericPrice / numericOldPrice) * 100)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
id: String(raw.id ?? ""),
|
// Group sources (store-specials, brand-groups, store-items) wrap a store-item
|
||||||
// Group sources (store-specials, brand-groups, store-items) don't include a
|
// with its own `id`, but also carry `productId` pointing at the real product —
|
||||||
// product slug, so leave it empty rather than fabricating a broken URL from
|
// prefer that so productHref can resolve a detail page even without a slug.
|
||||||
// the id — callers should fall back to a name search when this is empty.
|
id: String(raw.productId ?? raw.id ?? ""),
|
||||||
|
// Group sources don't include a product slug, so leave it empty — productHref
|
||||||
|
// falls back to an id-based lookup (which redirects to the canonical slug)
|
||||||
|
// rather than fabricating a broken URL from the id.
|
||||||
slug: String(raw.slug ?? ""),
|
slug: String(raw.slug ?? ""),
|
||||||
// Prefer the Persian name — sanihome.ir is a Persian storefront.
|
// Prefer the Persian name — sanihome.ir is a Persian storefront.
|
||||||
name: String(
|
name: String(
|
||||||
@@ -145,9 +163,9 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
|||||||
? brand
|
? brand
|
||||||
: ((brand as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
: ((brand as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||||
(raw.brandName as string | undefined),
|
(raw.brandName as string | undefined),
|
||||||
price: price != null ? Number(price) : null,
|
price: numericPrice,
|
||||||
oldPrice: oldPrice != null ? Number(oldPrice) : undefined,
|
oldPrice: numericOldPrice,
|
||||||
discount: raw.discount != null ? Number(raw.discount) : undefined,
|
discount,
|
||||||
image:
|
image:
|
||||||
(raw.thumbnailUrl as string | undefined) ??
|
(raw.thumbnailUrl as string | undefined) ??
|
||||||
(raw.image as string | undefined) ??
|
(raw.image as string | undefined) ??
|
||||||
@@ -161,9 +179,11 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function productHref(product: Product): string {
|
export function productHref(product: Product): string {
|
||||||
return product.slug
|
if (product.slug) return `/products/${product.slug}`;
|
||||||
? `/products/${product.slug}`
|
// No slug (store-specials/brand-groups/store-items don't return one) — route
|
||||||
: `/products?q=${encodeURIComponent(product.name)}`;
|
// through the id-based resolver, which redirects to the canonical slug page.
|
||||||
|
if (product.id) return `/products/id/${product.id}`;
|
||||||
|
return `/products?q=${encodeURIComponent(product.name)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProducts(params?: {
|
export async function getProducts(params?: {
|
||||||
@@ -221,6 +241,15 @@ export async function getProduct(slug: string): Promise<ProductDetail | null> {
|
|||||||
return res?.product ? normalizeProductDetail(res.product) : null;
|
return res?.product ? normalizeProductDetail(res.product) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used to resolve a canonical slug for cards sourced from an endpoint that
|
||||||
|
// only gives us a product id (store-specials, brand-groups, store-items).
|
||||||
|
export async function getProductById(id: string): Promise<ProductDetail | null> {
|
||||||
|
const res = await apiGet<{ product: Record<string, unknown> }>(
|
||||||
|
`/tenants/${DOMAIN}/products/by-id/${id}`,
|
||||||
|
);
|
||||||
|
return res?.product ? normalizeProductDetail(res.product) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export type VariationOption = {
|
export type VariationOption = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user