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:
co-authored by
Cursor
parent
cd29448c4c
commit
8aeba175db
@@ -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
@@ -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 |
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user