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-light: var(--red-light);
|
||||
--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);
|
||||
}
|
||||
|
||||
@@ -38,11 +39,21 @@ html {
|
||||
body {
|
||||
background: var(--background);
|
||||
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-family: var(--font-syne), var(--font-vazirmatn), sans-serif;
|
||||
font-family: var(--font-syne), var(--font-iranyekan), sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
|
||||
+14
-6
@@ -1,15 +1,23 @@
|
||||
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 { TopBar } from "@/components/home/TopBar";
|
||||
import { Header } from "@/components/home/Header";
|
||||
import { Footer } from "@/components/home/Footer";
|
||||
import { getBusinessInfo, getCategories, getCategoryTree } from "@/lib/meshkee";
|
||||
|
||||
const vazirmatn = Vazirmatn({
|
||||
variable: "--font-vazirmatn",
|
||||
subsets: ["arabic", "latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
// Body text — headings use the bold weight instead, see globals.css.
|
||||
const iranyekan = localFont({
|
||||
variable: "--font-iranyekan",
|
||||
src: "../../public/fonts/iranyekanweblightfanum.woff",
|
||||
weight: "400",
|
||||
});
|
||||
|
||||
const iranyekanBold = localFont({
|
||||
variable: "--font-iranyekan-bold",
|
||||
src: "../../public/fonts/iranyekanwebboldfanum.woff",
|
||||
weight: "700",
|
||||
});
|
||||
|
||||
const syne = Syne({
|
||||
@@ -34,7 +42,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
<html
|
||||
lang="fa"
|
||||
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">
|
||||
<TopBar />
|
||||
|
||||
@@ -60,7 +60,12 @@ export default async function ProductDetailPage({
|
||||
}
|
||||
variations={variations}
|
||||
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}
|
||||
contactPhone={contactPhone}
|
||||
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 "swiper/css";
|
||||
import "swiper/css/free-mode";
|
||||
import { Placeholder } from "@/components/ui/Placeholder";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import type { Category, StaticImageWithUrl } from "@/lib/meshkee";
|
||||
@@ -13,9 +12,9 @@ function CategorySlotCard({ image }: { image: StaticImageWithUrl }) {
|
||||
const label = image.titleFa ?? image.titleEn ?? "";
|
||||
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="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 */}
|
||||
<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>
|
||||
{label && (
|
||||
<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"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
>
|
||||
<Placeholder
|
||||
<div className="mb-3 w-[110px] overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={cat.image ?? "/images/placeholder.svg"}
|
||||
alt={cat.name}
|
||||
width="72px"
|
||||
height="72px"
|
||||
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">
|
||||
{cat.name}
|
||||
</span>
|
||||
|
||||
@@ -30,7 +30,12 @@ export function ProductPurchasePanel({
|
||||
header: ReactNode;
|
||||
variations: ProductVariation[];
|
||||
storeVariants: ProductStoreVariant[];
|
||||
fallbackPrice: { minPrice: number | null; maxPrice: number | null };
|
||||
fallbackPrice: {
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
oldPrice?: number | null;
|
||||
discount?: number;
|
||||
};
|
||||
inStock: boolean;
|
||||
contactPhone?: string;
|
||||
storeName?: string;
|
||||
@@ -68,6 +73,17 @@ export function ProductPurchasePanel({
|
||||
? `${formatPrice(fallbackPrice.minPrice)} - ${formatPrice(fallbackPrice.maxPrice)}`
|
||||
: 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 available = hasVariations ? Boolean(matchedVariant) && stockQuantity! > 0 : inStock;
|
||||
const maxQty = stockQuantity && stockQuantity > 0 ? stockQuantity : 99;
|
||||
@@ -137,10 +153,22 @@ export function ProductPurchasePanel({
|
||||
|
||||
{price != null ? (
|
||||
<>
|
||||
<p className="mb-5 text-2xl font-bold text-red-dark">
|
||||
<div className="mb-5 space-y-1.5">
|
||||
{!priceRangeLabel && oldPrice != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
<span className="text-sm font-medium text-foreground">تعداد:</span>
|
||||
|
||||
@@ -79,7 +79,11 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
className="!px-1 !py-2"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -36,11 +36,6 @@ export function ProductCard({
|
||||
fit="contain"
|
||||
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" && (
|
||||
<span className="font-display absolute top-3 left-3 text-[10px] font-bold tracking-widest text-red-dark uppercase">
|
||||
{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">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-bold text-red-dark">
|
||||
<div className="space-y-1">
|
||||
{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 ? (
|
||||
<>
|
||||
{formatPrice(price)}
|
||||
@@ -64,12 +69,7 @@ export function ProductCard({
|
||||
) : (
|
||||
<span className="text-sm font-normal text-muted">تماس با فروشگاه</span>
|
||||
)}
|
||||
</span>
|
||||
{oldPrice && (
|
||||
<span className="text-xs text-muted line-through">
|
||||
{formatPrice(oldPrice)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -77,9 +77,15 @@ export function ProductCard({
|
||||
|
||||
return (
|
||||
<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">
|
||||
{price != null ? (
|
||||
<button
|
||||
|
||||
+40
-11
@@ -129,12 +129,30 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
const firstVariant = variants?.[0];
|
||||
const 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 {
|
||||
id: String(raw.id ?? ""),
|
||||
// Group sources (store-specials, brand-groups, store-items) don't include a
|
||||
// product slug, so leave it empty rather than fabricating a broken URL from
|
||||
// the id — callers should fall back to a name search when this is empty.
|
||||
// Group sources (store-specials, brand-groups, store-items) wrap a store-item
|
||||
// with its own `id`, but also carry `productId` pointing at the real product —
|
||||
// prefer that so productHref can resolve a detail page even without a slug.
|
||||
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 ?? ""),
|
||||
// Prefer the Persian name — sanihome.ir is a Persian storefront.
|
||||
name: String(
|
||||
@@ -145,9 +163,9 @@ function normalizeProduct(raw: Record<string, unknown>): Product {
|
||||
? brand
|
||||
: ((brand as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||
(raw.brandName as string | undefined),
|
||||
price: price != null ? Number(price) : null,
|
||||
oldPrice: oldPrice != null ? Number(oldPrice) : undefined,
|
||||
discount: raw.discount != null ? Number(raw.discount) : undefined,
|
||||
price: numericPrice,
|
||||
oldPrice: numericOldPrice,
|
||||
discount,
|
||||
image:
|
||||
(raw.thumbnailUrl 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 {
|
||||
return product.slug
|
||||
? `/products/${product.slug}`
|
||||
: `/products?q=${encodeURIComponent(product.name)}`;
|
||||
if (product.slug) return `/products/${product.slug}`;
|
||||
// No slug (store-specials/brand-groups/store-items don't return one) — route
|
||||
// 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?: {
|
||||
@@ -221,6 +241,15 @@ export async function getProduct(slug: string): Promise<ProductDetail | 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 = {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user