Refresh homepage hero, category carousels, and product UI.

Add livestock/poultry/additive carousels, hero category icon slot, and product card polish while restoring the vineyard hero background.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 13:51:43 +03:30
co-authored by Cursor
parent cd29448c4c
commit 8aeba175db
15 changed files with 519 additions and 218 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ can fill with their own images from the Meshkee dashboard. Current mapping:
| Slot key | Kind | Component | Fallback when empty/unset |
|-------------------|--------|--------------------|----------------------------------------|
| `slider` | list | `HeroSlider` | 3 local photos in `hero-slider-*.jpg` with authored copy |
| `hero-category-icons` | list (duplicatable) | `HeroSlider` boxes | SVG icons + top-level category names |
| `categories` | list | `CategoryShowcase` | photo cards derived from live product categories |
| `four-icon-image` | list(4)| `Features` | 4 local icons + authored copy, per-index override |
| `about-us` | single | `About` | local `hero-2.jpg` |
+1 -1
View File
@@ -21,7 +21,7 @@ Consumers read `GET /tenants/aradarisman.com/website/static-images` and match by
| Key | Kind | itemCount | Aspect | Width | Component |
|---|---|---|---|---|---|
| `slider` | list (duplicatable) | null | 16:9 | 1920 | `HeroSlider` (full-viewport `h-svh`; catalog ratio is the recommended crop) |
| `hero-category-icons` | list (duplicatable) | null | 1:1 | 256 | `HeroSlider` category boxes (square icons) |
| `categories` | list (duplicatable) | null | 3:5 | 720 | `CategoryShowcase` |
| `four-icon-image` | list | 4 | 1:1 | 128 | `Features` |
| `about-us` | single | 1 | 4:3 | 960 | `About` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

After

Width:  |  Height:  |  Size: 634 KiB

+1
View File
@@ -19,6 +19,7 @@
--color-accent: var(--accent);
--color-surface: var(--surface);
--font-sans: var(--font-vazirmatn);
--font-display: var(--font-display-en);
}
body {
+8 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Vazirmatn } from "next/font/google";
import { Montserrat, Vazirmatn } from "next/font/google";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Providers from "@/components/Providers";
@@ -13,6 +13,12 @@ const vazirmatn = Vazirmatn({
weight: ["300", "400", "500", "600", "700", "800"],
});
const montserrat = Montserrat({
variable: "--font-display-en",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const defaultFavicon = "/favicon.ico";
export async function generateMetadata(): Promise<Metadata> {
@@ -41,7 +47,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
<html
lang="fa"
dir="rtl"
className={`${vazirmatn.variable} h-full antialiased`}
className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Providers businessId={businessId}>
+4 -4
View File
@@ -5,13 +5,13 @@
*/
const slots = [
{
key: "slider",
label: "اسلایدر صفحه اصلی",
key: "hero-category-icons",
label: "آیکون‌های دسته‌بندی هیرو",
kind: "list",
pageKey: "home",
aspectRatio: "16:9",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 1920,
recommendedWidth: 256,
},
{
key: "categories",
+30 -3
View File
@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import HeroSlider from "@/components/HeroSlider";
import CategoryShowcase from "@/components/CategoryShowcase";
import CategoryProducts from "@/components/CategoryProducts";
import HomeContactStrip from "@/components/HomeContactStrip";
import Features from "@/components/Features";
import Stats from "@/components/Stats";
import Products from "@/components/Products";
import About from "@/components/About";
import { getStaticImageSlot } from "@/lib/meshkee";
import { getStaticImageSlot, getTopLevelCategories } from "@/lib/meshkee";
import { categoryPath } from "@/lib/urls";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({
@@ -17,17 +19,42 @@ export const metadata: Metadata = pageMetadata({
});
export default async function Home() {
const sliderSlot = await getStaticImageSlot("slider");
const [categoryIconSlot, topCategories] = await Promise.all([
getStaticImageSlot("hero-category-icons"),
getTopLevelCategories(),
]);
const heroCategories = topCategories.slice(0, 5).map((cat) => ({
id: cat.id,
nameFa: cat.nameFa,
nameEn: cat.name,
href: categoryPath(cat),
}));
return (
<>
<HeroSlider slotImages={sliderSlot?.images ?? []} />
<HeroSlider
categories={heroCategories}
categoryIconImages={categoryIconSlot?.images ?? []}
/>
<CategoryProducts
categoryNameEn="Livestock"
className="py-14 sm:py-16 bg-surface"
/>
<CategoryShowcase />
<HomeContactStrip />
<CategoryProducts
categoryNameEn="Poultry"
className="py-14 sm:py-16"
/>
<Features />
<Stats />
<Products />
<About />
<CategoryProducts
categoryNameEn="Additive"
className="py-14 sm:py-16 bg-surface"
/>
</>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Container from "./Container";
import ProductsCarousel from "./ProductsCarousel";
import {
getCategories,
getCategoryDescendantIds,
getProducts,
} from "@/lib/meshkee";
import { categoryPath } from "@/lib/urls";
/** Match a top-level category by English `name` (e.g. "Livestock"). */
export default async function CategoryProducts({
categoryNameEn,
className = "py-16 sm:py-20",
limit = 8,
}: {
categoryNameEn: string;
className?: string;
limit?: number;
}) {
const [categories, { items }] = await Promise.all([
getCategories(),
getProducts({ pageSize: 100 }),
]);
const category =
categories.find(
(c) =>
c.parentId === null &&
c.name.toLowerCase() === categoryNameEn.toLowerCase()
) ?? null;
if (!category) return null;
const allowedIds = new Set(getCategoryDescendantIds(category.id, categories));
const products = items
.filter((p) => p.categoryId && allowedIds.has(p.categoryId))
.slice(0, limit);
if (products.length === 0) return null;
return (
<section className={className}>
<Container>
<ProductsCarousel
products={products}
titleFa={category.nameFa}
titleEn={category.name}
moreHref={categoryPath(category)}
/>
</Container>
</section>
);
}
+214 -140
View File
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toFa } from "@/lib/format";
import { categoryIcon } from "@/lib/categoryIcons";
import type { StaticImageItem } from "@/lib/meshkee";
type Slide = {
@@ -14,49 +15,80 @@ type Slide = {
linkUrl?: string | null;
};
export type HeroCategory = {
id: string;
nameFa: string;
nameEn: string;
href: string;
};
type CategoryBox = {
key: string;
href: string;
nameFa: string;
nameEn: string;
iconUrl: string | null;
};
const fallbackSlides: Slide[] = [
{
image: "/images/hero-slider-1.jpg",
image: "/images/hero-bg.jpg",
eyebrow: "بیش از ۲۳ سال تجربه",
title: "کیفیتی که به آن اعتماد دارید",
subtitle: "بیش از ۲۳۰ مشتری در سراسر کشور، انتخاب اول خود را کرده‌اند",
},
{
image: "/images/hero-slider-2.jpg",
eyebrow: "دام سالم، تولید پایدار",
title: "همراه شما در تمام دوره‌های پرورش",
subtitle:
"از دوره پروار تا شیردهی؛ مکمل‌هایی متناسب با نیاز واقعی گله شما",
},
{
image: "/images/hero-slider-3.jpg",
eyebrow: "طیور پرقدرت",
title: "افزایش بازده با تغذیه اصولی",
subtitle:
"بهبود کیفیت تخم و گوشت، تقویت ایمنی و رشد یکنواخت گله طیور",
},
// Temporarily disabled
// {
// image: "/images/hero-slider-2.jpg",
// eyebrow: "دام سالم، تولید پایدار",
// title: "همراه شما در تمام دوره‌های پرورش",
// subtitle:
// "از دوره پروار تا شیردهی؛ مکمل‌هایی متناسب با نیاز واقعی گله شما",
// },
// {
// image: "/images/hero-slider-3.jpg",
// eyebrow: "طیور پرقدرت",
// title: "افزایش بازده با تغذیه اصولی",
// subtitle:
// "بهبود کیفیت تخم و گوشت، تقویت ایمنی و رشد یکنواخت گله طیور",
// },
];
const AUTOPLAY_MS = 6000;
const CATEGORIES_TITLE = "با بهترین ها برای دام شما، در کنارتان هستیم";
const ICON_SIZE = 68;
export default function HeroSlider({
slotImages = [],
categories = [],
categoryIconImages = [],
}: {
slotImages?: StaticImageItem[];
categories?: HeroCategory[];
categoryIconImages?: StaticImageItem[];
}) {
const slides = useMemo<Slide[]>(() => {
if (slotImages.length === 0) return fallbackSlides;
return slotImages.map((item) => ({
image: item.url,
eyebrow: undefined,
title: item.titleFa || undefined,
subtitle: item.subtext || undefined,
linkUrl: item.linkUrl,
const slides = fallbackSlides;
const categoryBoxes = useMemo<CategoryBox[]>(() => {
if (categoryIconImages.length > 0) {
return categoryIconImages.map((item, i) => ({
key: item.id,
href: item.linkUrl || categories[i]?.href || "/products",
nameFa: item.titleFa || categories[i]?.nameFa || "",
nameEn: item.titleEn || item.title || categories[i]?.nameEn || "",
iconUrl: item.url,
}));
}
return categories.slice(0, 5).map((cat) => ({
key: cat.id,
href: cat.href,
nameFa: cat.nameFa,
nameEn: cat.nameEn,
iconUrl: null,
}));
}, [slotImages]);
}, [categoryIconImages, categories]);
const [index, setIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const multiSlide = slides.length > 1;
const goTo = useCallback(
(i: number) => {
@@ -69,25 +101,31 @@ export default function HeroSlider({
const prev = useCallback(() => goTo(index - 1), [goTo, index]);
useEffect(() => {
if (!multiSlide) return;
timerRef.current = setInterval(() => {
setIndex((i) => (i + 1) % slides.length);
}, AUTOPLAY_MS);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [index, slides.length]);
}, [index, slides.length, multiSlide]);
function pause() {
if (timerRef.current) clearInterval(timerRef.current);
}
function resume() {
if (!multiSlide) return;
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setIndex((i) => (i + 1) % slides.length);
}, AUTOPLAY_MS);
}
const showCategories = index === 0 && categoryBoxes.length > 0;
const activeSlide = slides[index];
const showBottomButtons = Boolean(activeSlide && !activeSlide.linkUrl);
return (
<section
role="region"
@@ -129,130 +167,166 @@ export default function HeroSlider({
/>
)}
{hasCopy && (
<>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 pointer-events-none" />
{((hasCopy && i !== 0) || (i === 0 && categoryBoxes.length > 0)) && (
<div className="absolute inset-0 bg-gradient-to-b from-black/45 via-black/25 to-black/60 pointer-events-none" />
)}
<div className="relative z-10 flex h-full items-end pb-20 sm:pb-28 pt-[125px] pointer-events-none">
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
<div className="max-w-3xl text-center sm:text-start mx-auto sm:mx-0 sm:ps-12">
{slide.eyebrow && (
<span
className={`inline-block rounded-full bg-white/10 backdrop-blur px-4 py-1.5 text-sm font-semibold text-accent transition-all duration-700 delay-200 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.eyebrow}
</span>
)}
{slide.title &&
(i === index ? (
<h1
className={`mt-5 text-[clamp(1.1rem,3.6vw,3rem)] font-extrabold leading-tight text-white whitespace-nowrap transition-all duration-700 delay-300 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.title}
</h1>
) : (
<p
aria-hidden
className={`mt-5 text-[clamp(1.1rem,3.6vw,3rem)] font-extrabold leading-tight text-white whitespace-nowrap transition-all duration-700 delay-300 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.title}
</p>
))}
{slide.subtitle && (
<p
className={`mt-5 text-base sm:text-lg text-white/85 transition-all duration-700 delay-500 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.subtitle}
</p>
)}
{!slide.linkUrl && (
<div
className={`mt-8 flex flex-wrap justify-center sm:justify-start gap-4 pointer-events-auto transition-all duration-700 delay-700 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
<Link
href="/products"
className="rounded-full bg-primary px-7 py-3.5 text-white font-semibold hover:bg-primary-dark transition-colors"
>
مشاهده محصولات
</Link>
<Link
href="/contact"
className="rounded-full border border-white/40 px-7 py-3.5 text-white font-semibold hover:bg-white/10 transition-colors"
>
تماس با ما
</Link>
</div>
)}
</div>
{hasCopy && i !== 0 && (
<div className="relative z-10 flex h-full items-center justify-center pt-[125px] pb-28 pointer-events-none">
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
<div className="mx-auto max-w-3xl text-center">
{slide.eyebrow && (
<span
className={`inline-block rounded-full bg-white/10 backdrop-blur px-4 py-1.5 text-sm font-semibold text-accent transition-all duration-700 delay-200 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.eyebrow}
</span>
)}
{slide.title && (
<h2
className={`mt-4 text-[clamp(1.25rem,3.6vw,2.75rem)] font-extrabold leading-tight text-white transition-all duration-700 delay-300 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.title}
</h2>
)}
{slide.subtitle && (
<p
className={`mt-3 text-base sm:text-lg text-white/85 transition-all duration-700 delay-500 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.subtitle}
</p>
)}
</div>
</div>
</>
</div>
)}
</div>
);
})}
<button
type="button"
onClick={prev}
aria-label="اسلاید قبلی"
className="absolute top-1/2 start-3 sm:start-6 z-20 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors sm:flex"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="rotate-180">
<path
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
fill="currentColor"
/>
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="اسلاید بعدی"
className="absolute top-1/2 end-3 sm:end-6 z-20 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors sm:flex"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
fill="currentColor"
/>
</svg>
</button>
{showCategories && (
<div className="absolute inset-0 z-30 flex items-center justify-center px-4 pointer-events-none">
<div className="pointer-events-auto flex w-max max-w-full flex-col items-center gap-6 sm:gap-8">
<h1 className="max-w-3xl px-2 text-center text-[clamp(1.15rem,3.2vw,2.15rem)] font-extrabold leading-relaxed text-white">
{CATEGORIES_TITLE}
</h1>
<div className="flex w-max max-w-full gap-3 overflow-x-auto sm:gap-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{categoryBoxes.map((box) => (
<Link
key={box.key}
href={box.href}
className="group relative flex w-[9.5rem] shrink-0 flex-col items-center gap-3 overflow-hidden rounded-2xl border border-white/55 bg-white/15 px-3.5 py-6 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] backdrop-blur-md transition-[border-color] duration-300 hover:border-primary sm:w-[12.5rem] sm:gap-3.5 sm:px-5 sm:py-8 md:w-[13.5rem]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-primary transition-transform duration-500 ease-out group-hover:scale-y-100"
/>
<span className="relative z-10 flex h-16 w-16 items-center justify-center overflow-hidden rounded-xl text-white sm:h-[4.75rem] sm:w-[4.75rem]">
{box.iconUrl ? (
<Image
src={box.iconUrl}
alt={box.nameFa || box.nameEn || "دسته"}
width={ICON_SIZE}
height={ICON_SIZE}
className="h-full w-full object-contain"
/>
) : (
categoryIcon(box.nameFa, ICON_SIZE)
)}
</span>
<span className="relative z-10 flex flex-col gap-1.5">
{box.nameFa && (
<span className="text-base font-extrabold leading-5 text-white sm:text-lg">
{box.nameFa}
</span>
)}
{box.nameEn && (
<span className="font-display text-xs font-medium tracking-wide text-white/80 sm:text-sm">
{box.nameEn}
</span>
)}
</span>
</Link>
))}
</div>
</div>
</div>
)}
<div className="absolute bottom-6 inset-x-0 z-20 flex items-center justify-center gap-2">
{slides.map((slide, i) => (
{showBottomButtons && (
<div className="absolute inset-x-0 bottom-10 z-30 flex flex-wrap justify-center gap-4 px-4 sm:bottom-12">
<Link
href="/products"
className="rounded-full bg-primary px-7 py-3.5 text-white font-semibold hover:bg-primary-dark transition-colors"
>
مشاهده محصولات
</Link>
<Link
href="/contact"
className="rounded-full border border-white/40 px-7 py-3.5 text-white font-semibold hover:bg-white/10 transition-colors"
>
تماس با ما
</Link>
</div>
)}
{multiSlide && (
<>
<button
key={slide.image + i}
type="button"
onClick={() => goTo(i)}
aria-label={`رفتن به اسلاید ${toFa(i + 1)}`}
aria-current={i === index}
className={`h-2 rounded-full transition-all ${
i === index ? "w-8 bg-accent" : "w-2 bg-white/50 hover:bg-white/80"
}`}
/>
))}
</div>
onClick={prev}
aria-label="اسلاید قبلی"
className="absolute top-1/2 start-3 sm:start-6 z-40 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors sm:flex"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="rotate-180">
<path
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
fill="currentColor"
/>
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="اسلاید بعدی"
className="absolute top-1/2 end-3 sm:end-6 z-40 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-white backdrop-blur hover:bg-white/25 transition-colors sm:flex"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
fill="currentColor"
/>
</svg>
</button>
<div className="absolute bottom-3 inset-x-0 z-40 flex items-center justify-center gap-2">
{slides.map((slide, i) => (
<button
key={slide.image + i}
type="button"
onClick={() => goTo(i)}
aria-label={`رفتن به اسلاید ${toFa(i + 1)}`}
aria-current={i === index ? true : undefined}
className={`h-2 rounded-full transition-all ${
i === index ? "w-8 bg-accent" : "w-2 bg-white/50 hover:bg-white/80"
}`}
/>
))}
</div>
</>
)}
</section>
);
}
+12 -3
View File
@@ -7,11 +7,12 @@ import { productPath } from "@/lib/urls";
export default function ProductCard({ product }: { product: Product }) {
const image = productImage(product);
const excerpt = product.summary || stripHtml(product.descriptionHtml, 90);
const nameEn = product.title?.trim();
return (
<Link
href={productPath(product)}
className="group rounded-2xl border border-black/5 bg-white overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
className="group block rounded-2xl border border-black/12 bg-white overflow-hidden transition-colors hover:border-black"
>
<div className="relative aspect-square bg-surface">
{image ? (
@@ -34,11 +35,19 @@ export default function ProductCard({ product }: { product: Product }) {
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-secondary leading-6 line-clamp-2 min-h-12">
<h3 className="font-semibold text-secondary leading-6 line-clamp-2">
{product.nameFa}
</h3>
{nameEn && (
<p
dir="ltr"
className="mt-0.5 text-end font-display text-[0.7rem] font-medium leading-tight tracking-[0.02em] text-primary/50 line-clamp-1 transition-colors group-hover:text-primary"
>
{nameEn}
</p>
)}
{excerpt && (
<p className="mt-1 text-sm text-foreground line-clamp-2">{excerpt}</p>
<p className="mt-2 text-sm text-foreground line-clamp-2">{excerpt}</p>
)}
<div className="mt-3 flex items-center justify-between">
<span className="text-sm font-bold text-primary">
+6 -1
View File
@@ -10,7 +10,12 @@ export default async function Products() {
return (
<section id="products" className="py-16 sm:py-20">
<Container>
<ProductsCarousel products={featured} />
<ProductsCarousel
products={featured}
titleFa="محصولات جدید"
titleEn="New Products"
moreHref="/products"
/>
</Container>
</section>
);
+63 -43
View File
@@ -20,7 +20,17 @@ const ChevronIcon = ({ flipped }: { flipped?: boolean }) => (
</svg>
);
export default function ProductsCarousel({ products }: { products: Product[] }) {
export default function ProductsCarousel({
products,
titleFa = "محصولات جدید",
titleEn = "New Products",
moreHref = "/products",
}: {
products: Product[];
titleFa?: string;
titleEn?: string;
moreHref?: string;
}) {
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const [index, setIndex] = useState(0);
@@ -37,52 +47,62 @@ export default function ProductsCarousel({ products }: { products: Product[] })
return (
<div>
<div className="flex items-end justify-between flex-wrap gap-4">
<h2 className="text-3xl sm:text-4xl font-extrabold text-primary">
محصولات جدید
</h2>
<div className="flex items-center gap-5">
<Link
href="/products"
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
>
مشاهده بیشتر
<ChevronIcon />
</Link>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goTo(index - 1)}
disabled={index === 0}
aria-label="محصولات قبلی"
className="flex h-10 w-10 items-center justify-center rounded-full border border-black/10 text-secondary hover:border-primary hover:text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
<div>
<h2 className="flex flex-wrap items-baseline justify-start gap-x-2.5 gap-y-1 text-3xl sm:text-4xl font-extrabold text-primary leading-none">
<span>{titleFa}</span>
<span className="text-accent/70 text-2xl sm:text-3xl font-light" aria-hidden>
•
</span>
<span
dir="ltr"
className="font-display text-lg font-semibold tracking-[0.04em] text-accent sm:text-xl"
>
<ChevronIcon flipped />
</button>
<button
type="button"
onClick={() => goTo(index + 1)}
disabled={index === products.length - 1}
aria-label="محصولات بعدی"
className="flex h-10 w-10 items-center justify-center rounded-full border border-black/10 text-secondary hover:border-primary hover:text-primary transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronIcon />
</button>
</div>
{titleEn}
</span>
</h2>
</div>
<Link
href={moreHref}
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
>
مشاهده بیشتر
<ChevronIcon />
</Link>
</div>
<div className="mt-10 flex gap-5 sm:gap-6 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{products.map((product, i) => (
<div
key={product.id}
ref={(el) => {
cardRefs.current[i] = el;
}}
className="w-[70%] sm:w-[45%] lg:w-[23.5%] shrink-0 snap-start"
>
<ProductCard product={product} />
</div>
))}
<div className="relative mt-4 sm:mt-5">
<button
type="button"
onClick={() => goTo(index - 1)}
disabled={index === 0}
aria-label="محصولات قبلی"
className="absolute start-0 top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border border-black/15 bg-white/95 text-secondary shadow-md backdrop-blur transition-colors hover:border-black hover:text-black disabled:pointer-events-none disabled:opacity-30 sm:h-12 sm:w-12 -ms-1 sm:-ms-3"
>
<ChevronIcon flipped />
</button>
<button
type="button"
onClick={() => goTo(index + 1)}
disabled={index === products.length - 1}
aria-label="محصولات بعدی"
className="absolute end-0 top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border border-black/15 bg-white/95 text-secondary shadow-md backdrop-blur transition-colors hover:border-black hover:text-black disabled:pointer-events-none disabled:opacity-30 sm:h-12 sm:w-12 -me-1 sm:-me-3"
>
<ChevronIcon />
</button>
<div className="flex gap-2.5 sm:gap-3 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{products.map((product, i) => (
<div
key={product.id}
ref={(el) => {
cardRefs.current[i] = el;
}}
className="w-[70%] sm:w-[45%] lg:w-[24.2%] shrink-0 snap-start"
>
<ProductCard product={product} />
</div>
))}
</div>
</div>
</div>
);
+124 -18
View File
@@ -30,28 +30,134 @@ const ICONS: Record<string, React.ReactNode> = {
/>
),
سبوس: (
<path
d="M12 2c1 2 1 3.5 0 5-1-1.5-1-3 0-5ZM6 9c4.4-2 9.6-2 12 0-3.2 3-8.8 3-12 0Zm-2 5c5.4-2.6 10.6-2.6 16 0-4 3.4-12 3.4-16 0Zm3 5c3.4-1.4 6.6-1.4 10 0-2.4 1.7-7.6 1.7-10 0Z"
fill="currentColor"
/>
<>
{/* wheat ear */}
<path
d="M12 21V10"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M12 11c-2-.8-3.2-2-3.2-3.4C8.8 9.4 10.2 10.4 12 11Zm0 0c2-.8 3.2-2 3.2-3.4C15.2 9.4 13.8 10.4 12 11Z"
fill="currentColor"
/>
<path
d="M12 13.2c-2.2-.7-3.6-2-3.6-3.6 0 1.8 1.5 3 3.6 3.6Zm0 0c2.2-.7 3.6-2 3.6-3.6 0 1.8-1.5 3-3.6 3.6Z"
fill="currentColor"
/>
<path
d="M12 15.5c-2.4-.7-4-2.1-4-3.8 0 2 1.7 3.3 4 3.8Zm0 0c2.4-.7 4-2.1 4-3.8 0 2-1.7 3.3-4 3.8Z"
fill="currentColor"
/>
<path
d="M12 8.2c-1.5-.7-2.3-1.7-2.3-2.8C9.7 6.5 10.7 7.4 12 8.2Zm0 0c1.5-.7 2.3-1.7 2.3-2.8C14.3 6.5 13.3 7.4 12 8.2Z"
fill="currentColor"
/>
<circle cx="12" cy="5.2" r="1.2" fill="currentColor" />
</>
),
افزودنی: (
<>
{/* lab flask / drop */}
<path
d="M9.5 3.2h5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
<path
d="M10.4 3.2v3.4L7.2 13a4.5 4.5 0 0 0 4 6.8h1.6a4.5 4.5 0 0 0 4-6.8L13.6 6.6V3.2"
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
fill="none"
/>
<path
d="M8.4 13.4h7.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M11.2 16.2h1.6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</>
),
آبزیان: (
<path
d="M3 12c3-3.5 8-5.5 13-4.3-.3-1-.9-1.9-1.6-2.7a10 10 0 0 1 6.1 5c-1.3 1.6-3.4 2.7-5.5 3.4a10 10 0 0 1-3 5.6c.7-.8 1.3-1.7 1.6-2.7C8.6 17.5 3.6 15.5 3 12Zm14.5-.8a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"
fill="currentColor"
/>
<>
{/* fish */}
<path
d="M2.8 12c3.2-3.6 7.8-5.2 12.6-3.6-.3-1-.9-1.9-1.7-2.6a10 10 0 0 1 6.5 5.2c-1.3 1.6-3.4 2.7-5.5 3.3a10 10 0 0 1-2.8 5.4c.7-.8 1.3-1.6 1.6-2.5C10 18.6 5.2 16.4 2.8 12Z"
fill="currentColor"
/>
<circle cx="16.4" cy="11.2" r="1.15" fill="#0c2925" />
<path
d="M9.2 13.2c1.4.3 2.8.2 4.1-.2"
stroke="#0c2925"
strokeWidth="1.15"
strokeLinecap="round"
opacity="0.35"
/>
</>
),
طیور: (
<path
d="M13 3c3.9 0 7 3.1 7 7 0 2.6-1.4 4.9-3.5 6.1V19a2 2 0 0 1-2 2h-1v-2h-3v2H9a2 2 0 0 1-2-2v-1.5A7 7 0 0 1 6 10c0-.6.1-1.1.2-1.6C4.9 8.9 3.9 9.9 3.5 11.2 3.2 8.1 6 5 9.5 5c.9 0 1.7.2 2.5.5A7 7 0 0 1 13 3Zm2 5.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
fill="currentColor"
/>
<>
{/* rooster — comb, beak, body */}
<path
d="M13.2 5.2c.35-1.1 1.15-1.9 2.15-2.1.15 1.05-.25 1.95-.95 2.55"
stroke="currentColor"
strokeWidth="1.55"
strokeLinecap="round"
fill="none"
/>
<path
d="M11.6 6.4c.55-.85 1.45-1.35 2.45-1.35 2.35 0 4.15 2.05 4.15 4.55 0 1.55-.75 2.9-1.9 3.7l.1 1.85c0 .7-.55 1.25-1.25 1.25h-.7v-1.35h-1.85v1.35h-.7c-.7 0-1.25-.55-1.25-1.25v-.95A4.85 4.85 0 0 1 7.6 9.5c0-.85.2-1.65.55-2.35C7.2 7.5 6.35 8.05 5.85 8.95 5.7 7 7.2 5.35 9.3 5.35c.85 0 1.6.3 2.3 1.05Z"
fill="currentColor"
/>
<path
d="M18.9 9.15 21 8.2"
stroke="currentColor"
strokeWidth="1.55"
strokeLinecap="round"
/>
<circle cx="15.85" cy="9.05" r="0.9" fill="#0c2925" />
<path
d="M4.7 11.4c1.15-.15 2.15.15 2.95.85"
stroke="currentColor"
strokeWidth="1.45"
strokeLinecap="round"
/>
</>
),
دام: (
<path
d="M6 4c1.5 0 2.5 1 2.8 2.3C9.6 6.1 10.8 6 12 6s2.4.1 3.2.3C15.5 5 16.5 4 18 4c1.7 0 3 1.3 3 3 0 1.2-.7 2.2-1.7 2.7C19.7 10.9 20 12.2 20 13.5 20 17.6 16.4 21 12 21s-8-3.4-8-7.5c0-1.3.3-2.6.7-3.8C3.7 9.2 3 8.2 3 7c0-1.7 1.3-3 3-3Zm3 9a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm6 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
fill="currentColor"
/>
<>
{/* cow head */}
<path
d="M7 5.4c1 0 1.75.55 2.15 1.4.7-.25 1.5-.4 2.35-.4.85 0 1.65.15 2.35.4.4-.85 1.15-1.4 2.15-1.4 1.2 0 2.15.95 2.15 2.15 0 .7-.35 1.3-.9 1.7.35.95.55 2 .55 3.1 0 3.25-2.7 5.85-6.15 5.85S5.5 15.6 5.5 12.35c0-1.1.2-2.15.55-3.1-.55-.4-.9-1-.9-1.7C5.15 6.35 6.1 5.4 7 5.4Z"
fill="currentColor"
/>
<circle cx="9.55" cy="12.55" r="1.1" fill="#0c2925" />
<circle cx="14.45" cy="12.55" r="1.1" fill="#0c2925" />
<path
d="M10.7 15c.35.45.9.7 1.3.7s.95-.25 1.3-.7"
stroke="#0c2925"
strokeWidth="1.2"
strokeLinecap="round"
fill="none"
opacity="0.5"
/>
<path
d="M9.2 9.3h5.6"
stroke="#0c2925"
strokeWidth="1.15"
strokeLinecap="round"
opacity="0.28"
/>
</>
),
زنبور: (
<path
@@ -89,10 +195,10 @@ const DEFAULT_ICON = (
/>
);
export function categoryIcon(name: string) {
export function categoryIcon(name: string, size = 26) {
const key = Object.keys(ICONS).find((k) => name.includes(k));
return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
{key ? ICONS[key] : DEFAULT_ICON}
</svg>
);
+2 -2
View File
@@ -50,8 +50,8 @@ export async function getWebsiteFavicon(): Promise<WebsiteFavicon> {
}
/**
* Business-managed image overrides (hero slider, category tiles, feature
* icons, about photo, brand logos). Each slot has a stable `key`; when a
* Business-managed image overrides (hero category icons, category tiles,
* feature icons, about photo, brand logos). Each slot has a stable `key`; when a
* slot has no images (or the fetch fails) callers must keep their current
* placeholder — never render broken UI. See CLAUDE.md for the slot-key map.
*/