diff --git a/CLAUDE.md b/CLAUDE.md index 4a94e0e..89340b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/CONTEXT.md b/CONTEXT.md index de537b0..01bb5ad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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` | diff --git a/public/images/hero-bg.jpg b/public/images/hero-bg.jpg new file mode 100644 index 0000000..5f928bd Binary files /dev/null and b/public/images/hero-bg.jpg differ diff --git a/public/images/hero-slider-1.jpg b/public/images/hero-slider-1.jpg index 3f33dfa..5f928bd 100644 Binary files a/public/images/hero-slider-1.jpg and b/public/images/hero-slider-1.jpg differ diff --git a/src/app/globals.css b/src/app/globals.css index 7550ced..b787352 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -19,6 +19,7 @@ --color-accent: var(--accent); --color-surface: var(--surface); --font-sans: var(--font-vazirmatn); + --font-display: var(--font-display-en); } body { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 82ee252..1cef6c1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { @@ -41,7 +47,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { diff --git a/src/app/meshkee/static-image-slots/route.ts b/src/app/meshkee/static-image-slots/route.ts index 00ea86b..33e5631 100644 --- a/src/app/meshkee/static-image-slots/route.ts +++ b/src/app/meshkee/static-image-slots/route.ts @@ -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", diff --git a/src/app/page.tsx b/src/app/page.tsx index 94eb3ee..b813805 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <> - + + + + ); } diff --git a/src/components/CategoryProducts.tsx b/src/components/CategoryProducts.tsx new file mode 100644 index 0000000..b41694b --- /dev/null +++ b/src/components/CategoryProducts.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx index 485da20..0dd259d 100644 --- a/src/components/HeroSlider.tsx +++ b/src/components/HeroSlider.tsx @@ -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(() => { - 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(() => { + 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 | 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 (
)} - {hasCopy && ( - <> -
+ {((hasCopy && i !== 0) || (i === 0 && categoryBoxes.length > 0)) && ( +
+ )} -
-
-
- {slide.eyebrow && ( - - {slide.eyebrow} - - )} - {slide.title && - (i === index ? ( -

- {slide.title} -

- ) : ( -

- {slide.title} -

- ))} - {slide.subtitle && ( -

- {slide.subtitle} -

- )} - {!slide.linkUrl && ( -
- - مشاهده محصولات - - - تماس با ما - -
- )} -
+ {hasCopy && i !== 0 && ( +
+
+
+ {slide.eyebrow && ( + + {slide.eyebrow} + + )} + {slide.title && ( +

+ {slide.title} +

+ )} + {slide.subtitle && ( +

+ {slide.subtitle} +

+ )}
- +
)}
); })} - - + {showCategories && ( +
+
+

+ {CATEGORIES_TITLE} +

+
+ {categoryBoxes.map((box) => ( + + + + {box.iconUrl ? ( + {box.nameFa + ) : ( + categoryIcon(box.nameFa, ICON_SIZE) + )} + + + {box.nameFa && ( + + {box.nameFa} + + )} + {box.nameEn && ( + + {box.nameEn} + + )} + + + ))} +
+
+
+ )} -
- {slides.map((slide, i) => ( + {showBottomButtons && ( +
+ + مشاهده محصولات + + + تماس با ما + +
+ )} + + {multiSlide && ( + <>
+ 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" + > + + + + + + +
+ {slides.map((slide, i) => ( +
+ + )}
); } diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index ed3b1e4..c3e01d4 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -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 (
{image ? ( @@ -34,11 +35,19 @@ export default function ProductCard({ product }: { product: Product }) { )}
-

+

{product.nameFa}

+ {nameEn && ( +

+ {nameEn} +

+ )} {excerpt && ( -

{excerpt}

+

{excerpt}

)}
diff --git a/src/components/Products.tsx b/src/components/Products.tsx index 0927d2b..58bf059 100644 --- a/src/components/Products.tsx +++ b/src/components/Products.tsx @@ -10,7 +10,12 @@ export default async function Products() { return (
- +
); diff --git a/src/components/ProductsCarousel.tsx b/src/components/ProductsCarousel.tsx index 3996e31..f0db94e 100644 --- a/src/components/ProductsCarousel.tsx +++ b/src/components/ProductsCarousel.tsx @@ -20,7 +20,17 @@ const ChevronIcon = ({ flipped }: { flipped?: boolean }) => ( ); -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 (
-

- محصولات جدید -

-
- - مشاهده بیشتر - - -
- - -
+ {titleEn} + +
+ + مشاهده بیشتر + +
-
- {products.map((product, i) => ( -
{ - cardRefs.current[i] = el; - }} - className="w-[70%] sm:w-[45%] lg:w-[23.5%] shrink-0 snap-start" - > - -
- ))} +
+ + + +
+ {products.map((product, i) => ( +
{ + cardRefs.current[i] = el; + }} + className="w-[70%] sm:w-[45%] lg:w-[24.2%] shrink-0 snap-start" + > + +
+ ))} +
); diff --git a/src/lib/categoryIcons.tsx b/src/lib/categoryIcons.tsx index 6f5fad9..b700d8b 100644 --- a/src/lib/categoryIcons.tsx +++ b/src/lib/categoryIcons.tsx @@ -30,28 +30,134 @@ const ICONS: Record = { /> ), سبوس: ( - + <> + {/* wheat ear */} + + + + + + + + ), + افزودنی: ( + <> + {/* lab flask / drop */} + + + + + ), آبزیان: ( - + <> + {/* fish */} + + + + ), طیور: ( - + <> + {/* rooster — comb, beak, body */} + + + + + + ), دام: ( - + <> + {/* cow head */} + + + + + + ), زنبور: ( ); -export function categoryIcon(name: string) { +export function categoryIcon(name: string, size = 26) { const key = Object.keys(ICONS).find((k) => name.includes(k)); return ( - + {key ? ICONS[key] : DEFAULT_ICON} ); diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 681fe19..1fcca2e 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -50,8 +50,8 @@ export async function getWebsiteFavicon(): Promise { } /** - * 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. */