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:
amirhosein.ashourloo
2026-08-23 09:50:56 +03:30
co-authored by Claude Sonnet 5
parent 83809815c1
commit c2552af079
11 changed files with 158 additions and 52 deletions
Binary file not shown.
Binary file not shown.
+14 -3
View File
@@ -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
View File
@@ -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 />
+6 -1
View File
@@ -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}
+15
View File
@@ -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}`);
}
+7 -7
View File
@@ -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>
+5 -1
View File
@@ -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>
+21 -15
View File
@@ -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
View File
@@ -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;