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 | | 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 | | `categories` | list | `CategoryShowcase` | photo cards derived from live product categories |
| `four-icon-image` | list(4)| `Features` | 4 local icons + authored copy, per-index override | | `four-icon-image` | list(4)| `Features` | 4 local icons + authored copy, per-index override |
| `about-us` | single | `About` | local `hero-2.jpg` | | `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 | | 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` | | `categories` | list (duplicatable) | null | 3:5 | 720 | `CategoryShowcase` |
| `four-icon-image` | list | 4 | 1:1 | 128 | `Features` | | `four-icon-image` | list | 4 | 1:1 | 128 | `Features` |
| `about-us` | single | 1 | 4:3 | 960 | `About` | | `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-accent: var(--accent);
--color-surface: var(--surface); --color-surface: var(--surface);
--font-sans: var(--font-vazirmatn); --font-sans: var(--font-vazirmatn);
--font-display: var(--font-display-en);
} }
body { body {
+8 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Vazirmatn } from "next/font/google"; import { Montserrat, Vazirmatn } from "next/font/google";
import Header from "@/components/Header"; import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Providers from "@/components/Providers"; import Providers from "@/components/Providers";
@@ -13,6 +13,12 @@ const vazirmatn = Vazirmatn({
weight: ["300", "400", "500", "600", "700", "800"], 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"; const defaultFavicon = "/favicon.ico";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
@@ -41,7 +47,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
<html <html
lang="fa" lang="fa"
dir="rtl" dir="rtl"
className={`${vazirmatn.variable} h-full antialiased`} className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers businessId={businessId}> <Providers businessId={businessId}>
+4 -4
View File
@@ -5,13 +5,13 @@
*/ */
const slots = [ const slots = [
{ {
key: "slider", key: "hero-category-icons",
label: "اسلایدر صفحه اصلی", label: "آیکون‌های دسته‌بندی هیرو",
kind: "list", kind: "list",
pageKey: "home", pageKey: "home",
aspectRatio: "16:9", aspectRatio: "1:1",
itemCount: null, itemCount: null,
recommendedWidth: 1920, recommendedWidth: 256,
}, },
{ {
key: "categories", key: "categories",
+30 -3
View File
@@ -1,12 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import HeroSlider from "@/components/HeroSlider"; import HeroSlider from "@/components/HeroSlider";
import CategoryShowcase from "@/components/CategoryShowcase"; import CategoryShowcase from "@/components/CategoryShowcase";
import CategoryProducts from "@/components/CategoryProducts";
import HomeContactStrip from "@/components/HomeContactStrip"; import HomeContactStrip from "@/components/HomeContactStrip";
import Features from "@/components/Features"; import Features from "@/components/Features";
import Stats from "@/components/Stats"; import Stats from "@/components/Stats";
import Products from "@/components/Products"; import Products from "@/components/Products";
import About from "@/components/About"; 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"; import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = pageMetadata({ export const metadata: Metadata = pageMetadata({
@@ -17,17 +19,42 @@ export const metadata: Metadata = pageMetadata({
}); });
export default async function Home() { 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 ( return (
<> <>
<HeroSlider slotImages={sliderSlot?.images ?? []} /> <HeroSlider
categories={heroCategories}
categoryIconImages={categoryIconSlot?.images ?? []}
/>
<CategoryProducts
categoryNameEn="Livestock"
className="py-14 sm:py-16 bg-surface"
/>
<CategoryShowcase /> <CategoryShowcase />
<HomeContactStrip /> <HomeContactStrip />
<CategoryProducts
categoryNameEn="Poultry"
className="py-14 sm:py-16"
/>
<Features /> <Features />
<Stats /> <Stats />
<Products /> <Products />
<About /> <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 Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toFa } from "@/lib/format"; import { toFa } from "@/lib/format";
import { categoryIcon } from "@/lib/categoryIcons";
import type { StaticImageItem } from "@/lib/meshkee"; import type { StaticImageItem } from "@/lib/meshkee";
type Slide = { type Slide = {
@@ -14,49 +15,80 @@ type Slide = {
linkUrl?: string | null; 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[] = [ const fallbackSlides: Slide[] = [
{ {
image: "/images/hero-slider-1.jpg", image: "/images/hero-bg.jpg",
eyebrow: "بیش از ۲۳ سال تجربه", eyebrow: "بیش از ۲۳ سال تجربه",
title: "کیفیتی که به آن اعتماد دارید", title: "کیفیتی که به آن اعتماد دارید",
subtitle: "بیش از ۲۳۰ مشتری در سراسر کشور، انتخاب اول خود را کرده‌اند", subtitle: "بیش از ۲۳۰ مشتری در سراسر کشور، انتخاب اول خود را کرده‌اند",
}, },
{ // Temporarily disabled
image: "/images/hero-slider-2.jpg", // {
eyebrow: "دام سالم، تولید پایدار", // image: "/images/hero-slider-2.jpg",
title: "همراه شما در تمام دوره‌های پرورش", // eyebrow: "دام سالم، تولید پایدار",
subtitle: // title: "همراه شما در تمام دوره‌های پرورش",
"از دوره پروار تا شیردهی؛ مکمل‌هایی متناسب با نیاز واقعی گله شما", // subtitle:
}, // "از دوره پروار تا شیردهی؛ مکمل‌هایی متناسب با نیاز واقعی گله شما",
{ // },
image: "/images/hero-slider-3.jpg", // {
eyebrow: "طیور پرقدرت", // image: "/images/hero-slider-3.jpg",
title: "افزایش بازده با تغذیه اصولی", // eyebrow: "طیور پرقدرت",
subtitle: // title: "افزایش بازده با تغذیه اصولی",
"بهبود کیفیت تخم و گوشت، تقویت ایمنی و رشد یکنواخت گله طیور", // subtitle:
}, // "بهبود کیفیت تخم و گوشت، تقویت ایمنی و رشد یکنواخت گله طیور",
// },
]; ];
const AUTOPLAY_MS = 6000; const AUTOPLAY_MS = 6000;
const CATEGORIES_TITLE = "با بهترین ها برای دام شما، در کنارتان هستیم";
const ICON_SIZE = 68;
export default function HeroSlider({ export default function HeroSlider({
slotImages = [], categories = [],
categoryIconImages = [],
}: { }: {
slotImages?: StaticImageItem[]; categories?: HeroCategory[];
categoryIconImages?: StaticImageItem[];
}) { }) {
const slides = useMemo<Slide[]>(() => { const slides = fallbackSlides;
if (slotImages.length === 0) return fallbackSlides;
return slotImages.map((item) => ({ const categoryBoxes = useMemo<CategoryBox[]>(() => {
image: item.url, if (categoryIconImages.length > 0) {
eyebrow: undefined, return categoryIconImages.map((item, i) => ({
title: item.titleFa || undefined, key: item.id,
subtitle: item.subtext || undefined, href: item.linkUrl || categories[i]?.href || "/products",
linkUrl: item.linkUrl, 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 [index, setIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const multiSlide = slides.length > 1;
const goTo = useCallback( const goTo = useCallback(
(i: number) => { (i: number) => {
@@ -69,25 +101,31 @@ export default function HeroSlider({
const prev = useCallback(() => goTo(index - 1), [goTo, index]); const prev = useCallback(() => goTo(index - 1), [goTo, index]);
useEffect(() => { useEffect(() => {
if (!multiSlide) return;
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
setIndex((i) => (i + 1) % slides.length); setIndex((i) => (i + 1) % slides.length);
}, AUTOPLAY_MS); }, AUTOPLAY_MS);
return () => { return () => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
}; };
}, [index, slides.length]); }, [index, slides.length, multiSlide]);
function pause() { function pause() {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
} }
function resume() { function resume() {
if (!multiSlide) return;
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
setIndex((i) => (i + 1) % slides.length); setIndex((i) => (i + 1) % slides.length);
}, AUTOPLAY_MS); }, AUTOPLAY_MS);
} }
const showCategories = index === 0 && categoryBoxes.length > 0;
const activeSlide = slides[index];
const showBottomButtons = Boolean(activeSlide && !activeSlide.linkUrl);
return ( return (
<section <section
role="region" role="region"
@@ -129,130 +167,166 @@ export default function HeroSlider({
/> />
)} )}
{hasCopy && ( {((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="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 pointer-events-none" /> )}
<div className="relative z-10 flex h-full items-end pb-20 sm:pb-28 pt-[125px] pointer-events-none"> {hasCopy && i !== 0 && (
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8"> <div className="relative z-10 flex h-full items-center justify-center pt-[125px] pb-28 pointer-events-none">
<div className="max-w-3xl text-center sm:text-start mx-auto sm:mx-0 sm:ps-12"> <div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
{slide.eyebrow && ( <div className="mx-auto max-w-3xl text-center">
<span {slide.eyebrow && (
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 ${ <span
i === index 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 ${
? "translate-y-0 opacity-100" i === index
: "translate-y-4 opacity-0" ? "translate-y-0 opacity-100"
}`} : "translate-y-4 opacity-0"
> }`}
{slide.eyebrow} >
</span> {slide.eyebrow}
)} </span>
{slide.title && )}
(i === index ? ( {slide.title && (
<h1 <h2
className={`mt-5 text-[clamp(1.1rem,3.6vw,3rem)] font-extrabold leading-tight text-white whitespace-nowrap transition-all duration-700 delay-300 ${ 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 i === index
? "translate-y-0 opacity-100" ? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0" : "translate-y-4 opacity-0"
}`} }`}
> >
{slide.title} {slide.title}
</h1> </h2>
) : ( )}
<p {slide.subtitle && (
aria-hidden <p
className={`mt-5 text-[clamp(1.1rem,3.6vw,3rem)] font-extrabold leading-tight text-white whitespace-nowrap transition-all duration-700 delay-300 ${ className={`mt-3 text-base sm:text-lg text-white/85 transition-all duration-700 delay-500 ${
i === index i === index
? "translate-y-0 opacity-100" ? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0" : "translate-y-4 opacity-0"
}`} }`}
> >
{slide.title} {slide.subtitle}
</p> </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>
</div> </div>
</div> </div>
</> </div>
)} )}
</div> </div>
); );
})} })}
<button {showCategories && (
type="button" <div className="absolute inset-0 z-30 flex items-center justify-center px-4 pointer-events-none">
onClick={prev} <div className="pointer-events-auto flex w-max max-w-full flex-col items-center gap-6 sm:gap-8">
aria-label="اسلاید قبلی" <h1 className="max-w-3xl px-2 text-center text-[clamp(1.15rem,3.2vw,2.15rem)] font-extrabold leading-relaxed text-white">
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" {CATEGORIES_TITLE}
> </h1>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="rotate-180"> <div className="flex w-max max-w-full gap-3 overflow-x-auto sm:gap-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<path {categoryBoxes.map((box) => (
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" <Link
fill="currentColor" key={box.key}
/> href={box.href}
</svg> 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]"
</button> >
<button <span
type="button" aria-hidden
onClick={next} 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"
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" <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 ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"> <Image
<path src={box.iconUrl}
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" alt={box.nameFa || box.nameEn || "دسته"}
fill="currentColor" width={ICON_SIZE}
/> height={ICON_SIZE}
</svg> className="h-full w-full object-contain"
</button> />
) : (
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"> {showBottomButtons && (
{slides.map((slide, i) => ( <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 <button
key={slide.image + i}
type="button" type="button"
onClick={() => goTo(i)} onClick={prev}
aria-label={`رفتن به اسلاید ${toFa(i + 1)}`} aria-label="اسلاید قبلی"
aria-current={i === index} 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"
className={`h-2 rounded-full transition-all ${ >
i === index ? "w-8 bg-accent" : "w-2 bg-white/50 hover:bg-white/80" <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"
</div> />
</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> </section>
); );
} }
+12 -3
View File
@@ -7,11 +7,12 @@ import { productPath } from "@/lib/urls";
export default function ProductCard({ product }: { product: Product }) { export default function ProductCard({ product }: { product: Product }) {
const image = productImage(product); const image = productImage(product);
const excerpt = product.summary || stripHtml(product.descriptionHtml, 90); const excerpt = product.summary || stripHtml(product.descriptionHtml, 90);
const nameEn = product.title?.trim();
return ( return (
<Link <Link
href={productPath(product)} 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"> <div className="relative aspect-square bg-surface">
{image ? ( {image ? (
@@ -34,11 +35,19 @@ export default function ProductCard({ product }: { product: Product }) {
)} )}
</div> </div>
<div className="p-4"> <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} {product.nameFa}
</h3> </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 && ( {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"> <div className="mt-3 flex items-center justify-between">
<span className="text-sm font-bold text-primary"> <span className="text-sm font-bold text-primary">
+6 -1
View File
@@ -10,7 +10,12 @@ export default async function Products() {
return ( return (
<section id="products" className="py-16 sm:py-20"> <section id="products" className="py-16 sm:py-20">
<Container> <Container>
<ProductsCarousel products={featured} /> <ProductsCarousel
products={featured}
titleFa="محصولات جدید"
titleEn="New Products"
moreHref="/products"
/>
</Container> </Container>
</section> </section>
); );
+63 -43
View File
@@ -20,7 +20,17 @@ const ChevronIcon = ({ flipped }: { flipped?: boolean }) => (
</svg> </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 cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
@@ -37,52 +47,62 @@ export default function ProductsCarousel({ products }: { products: Product[] })
return ( return (
<div> <div>
<div className="flex items-end justify-between flex-wrap gap-4"> <div className="flex items-end justify-between flex-wrap gap-4">
<h2 className="text-3xl sm:text-4xl font-extrabold text-primary"> <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">
</h2> <span>{titleFa}</span>
<div className="flex items-center gap-5"> <span className="text-accent/70 text-2xl sm:text-3xl font-light" aria-hidden>
<Link •
href="/products" </span>
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors" <span
> dir="ltr"
مشاهده بیشتر className="font-display text-lg font-semibold tracking-[0.04em] text-accent sm:text-xl"
<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"
> >
<ChevronIcon flipped /> {titleEn}
</button> </span>
<button </h2>
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>
</div> </div>
<Link
href={moreHref}
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
>
مشاهده بیشتر
<ChevronIcon />
</Link>
</div> </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"> <div className="relative mt-4 sm:mt-5">
{products.map((product, i) => ( <button
<div type="button"
key={product.id} onClick={() => goTo(index - 1)}
ref={(el) => { disabled={index === 0}
cardRefs.current[i] = el; 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"
className="w-[70%] sm:w-[45%] lg:w-[23.5%] shrink-0 snap-start" >
> <ChevronIcon flipped />
<ProductCard product={product} /> </button>
</div> <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>
</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" {/* wheat ear */}
fill="currentColor" <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" {/* fish */}
fill="currentColor" <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" {/* rooster — comb, beak, body */}
fill="currentColor" <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" {/* cow head */}
fill="currentColor" <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 <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)); const key = Object.keys(ICONS).find((k) => name.includes(k));
return ( 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} {key ? ICONS[key] : DEFAULT_ICON}
</svg> </svg>
); );
+2 -2
View File
@@ -50,8 +50,8 @@ export async function getWebsiteFavicon(): Promise<WebsiteFavicon> {
} }
/** /**
* Business-managed image overrides (hero slider, category tiles, feature * Business-managed image overrides (hero category icons, category tiles,
* icons, about photo, brand logos). Each slot has a stable `key`; when a * 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 * 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. * placeholder — never render broken UI. See CLAUDE.md for the slot-key map.
*/ */