Redesign category showcase and convert products section to carousel

- Replace icon-based category tiles with curated photo cards (brown
  bottom overlay, single-row layout, taller aspect ratio, tighter gaps,
  no card frame, light brown section background) via new
  CategoryShowcase component and categoryIcons fallback images.
- Add ScrollReveal component for page-enter animations, used across
  the category showcase and other sections.
- Turn the homepage "New Products" grid into a horizontal snap-scroll
  carousel with prev/next controls (ProductsCarousel).
- Extend Pagination and the products page to preserve categoryId
  filters, and widen aspectRatioToCss to accept undefined.
- Document the static-image-slots system and API client conventions
  in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 09:10:31 +03:30
co-authored by Claude Sonnet 5
parent d486b2dd65
commit 26b8b64bf3
21 changed files with 752 additions and 186 deletions
+4
View File
@@ -42,3 +42,7 @@ next-env.d.ts
# claude code local settings
.claude/settings.local.json
# raw source photos (unoptimized originals; only the derived files in
# public/images/ are actually used by the site)
/public/Image Sample/
+51
View File
@@ -1 +1,52 @@
@AGENTS.md
# Arad Arisman — Meshkee backend integration
This site is a Next.js storefront for **aradarisman.com**, backed by the
Meshkee Website API (`https://api.meshkee.com/api/v1`, docs at
`https://api.meshkee.com/docs/website`). All backend access goes through
`src/lib/meshkee.ts` — do not call the API directly from components/pages.
- Products (`getProducts`, `getProduct`) and blog posts (`getPosts`,
`getPost`) are fetched live, tenant-scoped to `TENANT_DOMAIN`
(`aradarisman.com`). Blog field names (`Post` type) are **inferred** from
the Product/Category naming convention since no tenant has published
posts to verify against yet — accessors fall back across plausible field
names (`postTitle`, `postContent`, etc.). Re-check against a real
published post once one exists and simplify the fallbacks if the guess
was wrong.
- Category tiles are derived from live products (`getCategorySummary`), not
the raw `/categories` endpoint — that taxonomy has duplicate entries
sharing the same display name under different ids (backend data-entry
artifact). The summary merges by name and links to whichever id holds
the most products under that name.
## Static image slots (business-managed overrides)
`getStaticImageSlots` / `getStaticImageSlot(key)` read
`/tenants/{domain}/website/static-images` — named slots the business owner
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 |
| `categories` | list | `CategoryShowcase` | icon-based 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` |
| `brands` | list | `Partners` | 6 local partner logos |
**Rule: a slot with zero images (or a failed fetch) must never break the
page — always fall back to the existing local/derived content.**
`getStaticImageSlots` already swallows fetch errors and returns `[]`.
Each `StaticImageItem` carries `url`, optional `titleFa`/`subtext`/
`linkUrl`, and its own `width`/`height`. Respect `slot.aspectRatio`
(`"21:9"` etc, via `aspectRatioToCss`) for slot-driven image containers
instead of hardcoding a Tailwind aspect class — if you resize/recrop a
*local* fallback image during development, update the corresponding
component's fallback styling to match, since slot-driven and fallback
images render through the same container and must not look broken when
swapped.
`aradarisman.com` currently has **zero** slots configured, so the site
renders through the fallback path everywhere — this is expected, not a bug.
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+7 -4
View File
@@ -1,17 +1,20 @@
import HeroSlider from "@/components/HeroSlider";
import CategoryShowcase from "@/components/CategoryShowcase";
import Features from "@/components/Features";
import Categories from "@/components/Categories";
import Stats from "@/components/Stats";
import Products from "@/components/Products";
import About from "@/components/About";
import Partners from "@/components/Partners";
import { getStaticImageSlot } from "@/lib/meshkee";
export default async function Home() {
const sliderSlot = await getStaticImageSlot("slider");
export default function Home() {
return (
<>
<HeroSlider />
<HeroSlider slotImages={sliderSlot?.images ?? []} />
<CategoryShowcase />
<Features />
<Categories />
<Stats />
<Products />
<About />
+23 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import Container from "@/components/Container";
import PageBanner from "@/components/PageBanner";
import ProductCard from "@/components/ProductCard";
@@ -16,18 +17,20 @@ const PAGE_SIZE = 6;
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
searchParams: Promise<{ page?: string; categoryId?: string }>;
}) {
const { page } = await searchParams;
const { page, categoryId } = await searchParams;
const requestedPage = Math.max(1, Number(page) || 1);
const { items, total } = await getProducts({
page: requestedPage,
pageSize: PAGE_SIZE,
categoryId,
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(requestedPage, totalPages);
const activeCategoryName = categoryId ? items[0]?.categoryNameFa : null;
return (
<>
@@ -39,6 +42,23 @@ export default async function ProductsPage({
<section className="py-16 sm:py-20">
<Container>
{categoryId && (
<div className="mb-8 flex items-center justify-between flex-wrap gap-3 rounded-2xl bg-surface px-5 py-4">
<span className="text-sm text-foreground">
فیلتر شده بر اساس دسته‌بندی:{" "}
<strong className="text-primary">
{activeCategoryName || "دسته‌بندی انتخابی"}
</strong>
</span>
<Link
href="/products"
className="text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
>
حذف فیلتر
</Link>
</div>
)}
{items.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -51,6 +71,7 @@ export default async function ProductsPage({
currentPage={currentPage}
totalPages={totalPages}
basePath="/products"
extraParams={categoryId ? { categoryId } : {}}
/>
</>
) : (
+12 -4
View File
@@ -1,16 +1,24 @@
import Image from "next/image";
import Container from "./Container";
import { getStaticImageSlot } from "@/lib/meshkee";
export default async function About() {
const slot = await getStaticImageSlot("about-us");
const slotImage = slot?.images?.[0];
const imageSrc = slotImage?.url || "/images/hero-2.jpg";
const imageAlt = slotImage?.titleFa || "محصولات آراد آریسمان";
const objectPositionClass = slotImage ? "object-center" : "object-left";
export default function About() {
return (
<section id="about" className="py-16 sm:py-20 bg-surface">
<Container className="grid grid-cols-1 lg:grid-cols-2 items-center gap-12">
<div className="relative aspect-[4/3] rounded-3xl overflow-hidden shadow-xl order-2 lg:order-1">
<Image
src="/images/hero-2.jpg"
alt="محصولات آراد آریسمان"
src={imageSrc}
alt={imageAlt}
fill
className="object-cover object-left"
className={`object-cover ${objectPositionClass}`}
sizes="(min-width: 1024px) 45vw, 90vw"
/>
</div>
-55
View File
@@ -1,55 +0,0 @@
import Image from "next/image";
import Container from "./Container";
const categories = [
{ title: "مکمل ویتامینه مرغ گوشتی", img: "/images/category-1.jpg" },
{ title: "سبوس برنج فراوری شده", img: "/images/category-2.jpg" },
{ title: "مکمل غذایی دام", img: "/images/category-3.jpg" },
{ title: "کنسانتره طیور", img: "/images/category-4.jpg" },
{ title: "کنسانتره گوسفند", img: "/images/category-5.jpg" },
{ title: "مکمل معدنی مرغ گوشتی", img: "/images/category-6.jpg" },
{ title: "مکمل گوسفند", img: "/images/category-7.jpg" },
{ title: "مکمل زنبور عسل", img: "/images/category-8.jpg" },
{ title: "مکمل معدنی مرغ تخمگذار", img: "/images/category-9.jpg" },
{ title: "مکمل ویتامینه مرغ تخمگذار", img: "/images/category-10.jpg" },
];
export default function Categories() {
return (
<section className="py-16 sm:py-20 bg-surface">
<Container>
<div className="text-center max-w-xl mx-auto">
<span className="text-sm font-bold text-accent">آراد آریسمان</span>
<h2 className="mt-3 text-3xl sm:text-4xl font-extrabold text-primary">
تغذیه مطمئن، پرورش موفق
</h2>
<p className="mt-4 text-foreground">
دسته‌بندی کامل محصولات ما برای طیور، دام و زنبور عسل
</p>
</div>
<div className="mt-12 flex gap-5 overflow-x-auto pb-4 snap-x snap-mandatory [scrollbar-width:thin]">
{categories.map((c) => (
<a
key={c.title}
href="#products"
className="group relative shrink-0 snap-start w-[220px] sm:w-[240px] aspect-[3/4] rounded-2xl overflow-hidden shadow-md"
>
<Image
src={c.img}
alt={c.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
sizes="240px"
/>
<div className="absolute inset-0 bg-gradient-to-t from-secondary/90 via-secondary/20 to-transparent" />
<span className="absolute bottom-5 inset-x-4 text-white font-bold text-lg leading-7">
{c.title}
</span>
</a>
))}
</div>
</Container>
</section>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Image from "next/image";
import Link from "next/link";
import Container from "./Container";
import ScrollReveal from "./ScrollReveal";
import { getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
import { categoryFallbackImage } from "@/lib/categoryIcons";
import { toFa } from "@/lib/format";
type Tile = {
key: string;
href: string;
image: string;
title: string;
subtitle?: string;
};
export default async function CategoryShowcase() {
const [categories, slot] = await Promise.all([
getCategorySummary(6),
getStaticImageSlot("categories"),
]);
const slotImages = slot?.images ?? [];
const tiles: Tile[] =
slotImages.length > 0
? slotImages.map((item) => ({
key: item.id,
href: item.linkUrl || "/products",
image: item.url,
title: item.titleFa || "",
}))
: categories.map((cat) => ({
key: cat.categoryId,
href: `/products?categoryId=${cat.categoryId}`,
image: categoryFallbackImage(cat.name),
title: cat.name,
subtitle: `${toFa(cat.count)} محصول`,
}));
if (tiles.length === 0) return null;
return (
<section className="py-14 sm:py-16 bg-[#f7f0e7]">
<Container>
<ScrollReveal>
<div className="text-center max-w-xl mx-auto">
<span className="text-sm font-bold text-accent">دسته‌بندی محصولات</span>
<h2 className="mt-3 text-2xl sm:text-3xl font-extrabold text-primary">
محصول مورد نظر خود را پیدا کنید
</h2>
</div>
<div
className="mt-8 flex snap-x snap-mandatory gap-2 overflow-x-auto pb-2 sm:pb-0 sm:overflow-visible sm:grid sm:gap-2.5 [scrollbar-width:thin]"
style={{ gridTemplateColumns: `repeat(${tiles.length}, minmax(0, 1fr))` }}
>
{tiles.map((tile, i) => (
<ScrollReveal
key={tile.key}
delay={i * 100}
className="w-[45%] shrink-0 snap-start sm:w-auto sm:shrink"
>
<Link
href={tile.href}
className="group relative block aspect-[3/5] overflow-hidden rounded-2xl shadow-md transition-all duration-300 hover:-translate-y-1.5 hover:shadow-xl"
>
<Image
src={tile.image}
alt={tile.title || "دسته‌بندی محصول"}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
sizes="(min-width: 640px) 20vw, 45vw"
/>
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-[#2b1810] via-[#2b1810]/55 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4">
{tile.title && (
<span className="block font-bold text-white leading-6">
{tile.title}
</span>
)}
{tile.subtitle && (
<span className="mt-1 block text-xs text-white/75">
{tile.subtitle}
</span>
)}
</div>
</Link>
</ScrollReveal>
))}
</div>
</ScrollReveal>
</Container>
</section>
);
}
+15 -2
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import Container from "./Container";
import { getStaticImageSlot } from "@/lib/meshkee";
const features = [
{
@@ -24,12 +25,24 @@ const features = [
},
];
export default function Features() {
export default async function Features() {
const slot = await getStaticImageSlot("four-icon-image");
const slotImages = slot?.images ?? [];
const items = features.map((f, i) => {
const override = slotImages[i];
return {
icon: override?.url || f.icon,
title: override?.titleFa || f.title,
desc: override?.subtext || f.desc,
};
});
return (
<section className="py-16 sm:py-20">
<Container>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{features.map((f) => (
{items.map((f) => (
<div
key={f.title}
className="group rounded-2xl border border-black/5 bg-white p-7 shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all"
+130 -81
View File
@@ -2,17 +2,19 @@
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toFa } from "@/lib/format";
import type { StaticImageItem } from "@/lib/meshkee";
type Slide = {
image: string;
eyebrow: string;
title: string;
subtitle: string;
eyebrow?: string;
title?: string;
subtitle?: string;
linkUrl?: string | null;
};
const slides: Slide[] = [
const fallbackSlides: Slide[] = [
{
image: "/images/hero-slider-1.jpg",
eyebrow: "بیش از ۲۳ سال تجربه",
@@ -37,13 +39,31 @@ const slides: Slide[] = [
const AUTOPLAY_MS = 6000;
export default function HeroSlider() {
export default function HeroSlider({
slotImages = [],
}: {
slotImages?: 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,
}));
}, [slotImages]);
const [index, setIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const goTo = useCallback((i: number) => {
setIndex((i + slides.length) % slides.length);
}, []);
const goTo = useCallback(
(i: number) => {
setIndex((i + slides.length) % slides.length);
},
[slides.length]
);
const next = useCallback(() => goTo(index + 1), [goTo, index]);
const prev = useCallback(() => goTo(index - 1), [goTo, index]);
@@ -55,7 +75,7 @@ export default function HeroSlider() {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [index]);
}, [index, slides.length]);
function pause() {
if (timerRef.current) clearInterval(timerRef.current);
@@ -77,79 +97,108 @@ export default function HeroSlider() {
onMouseLeave={resume}
className="relative -mt-[125px] h-svh min-h-[520px] w-full overflow-hidden bg-secondary"
>
{slides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
i === index ? "opacity-100 z-10" : "opacity-0 z-0"
}`}
>
<Image
src={slide.image}
alt={slide.title}
fill
priority={i === 0}
className="object-cover"
sizes="100vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10" />
{slides.map((slide, i) => {
const hasCopy = Boolean(slide.title || slide.eyebrow || slide.subtitle);
return (
<div
key={slide.image + i}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
i === index ? "opacity-100 z-10" : "opacity-0 z-0"
}`}
>
{slide.linkUrl ? (
<Link href={slide.linkUrl} className="absolute inset-0 block" aria-label={slide.title || "مشاهده"}>
<Image
src={slide.image}
alt={slide.title || "آراد آریسمان"}
fill
priority={i === 0}
className="object-cover"
sizes="100vw"
/>
</Link>
) : (
<Image
src={slide.image}
alt={slide.title || "آراد آریسمان"}
fill
priority={i === 0}
className="object-cover"
sizes="100vw"
/>
)}
<div className="relative z-10 flex h-full items-center pt-[125px]">
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
<div className="max-w-xl text-center sm:text-start mx-auto sm:mx-0 sm:ps-12">
<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>
<h1
className={`mt-5 text-3xl sm:text-5xl font-extrabold leading-[1.25] text-white transition-all duration-700 delay-300 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.title}
</h1>
<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>
<div
className={`mt-8 flex flex-wrap justify-center sm:justify-start gap-4 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>
{hasCopy && (
<>
<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-center pt-[125px] pointer-events-none">
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
<div className="max-w-xl 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 && (
<h1
className={`mt-5 text-3xl sm:text-5xl font-extrabold leading-[1.25] text-white transition-all duration-700 delay-300 ${
i === index
? "translate-y-0 opacity-100"
: "translate-y-4 opacity-0"
}`}
>
{slide.title}
</h1>
)}
{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>
))}
);
})}
<button
type="button"
@@ -181,7 +230,7 @@ export default function HeroSlider() {
<div className="absolute bottom-6 inset-x-0 z-20 flex items-center justify-center gap-2">
{slides.map((slide, i) => (
<button
key={slide.title}
key={slide.image + i}
type="button"
onClick={() => goTo(i)}
aria-label={`رفتن به اسلاید ${toFa(i + 1)}`}
+6 -1
View File
@@ -5,17 +5,22 @@ export default function Pagination({
currentPage,
totalPages,
basePath,
extraParams = {},
}: {
currentPage: number;
totalPages: number;
basePath: string;
extraParams?: Record<string, string>;
}) {
if (totalPages <= 1) return null;
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
function hrefFor(page: number) {
return page === 1 ? basePath : `${basePath}?page=${page}`;
const params = new URLSearchParams(extraParams);
if (page !== 1) params.set("page", String(page));
const qs = params.toString();
return qs ? `${basePath}?${qs}` : basePath;
}
const prevDisabled = currentPage === 1;
+40 -12
View File
@@ -1,7 +1,8 @@
import Image from "next/image";
import Container from "./Container";
import { getStaticImageSlot } from "@/lib/meshkee";
const partners = [
const localPartners = [
"/images/partner-1.png",
"/images/partner-2.png",
"/images/partner-3.png",
@@ -10,21 +11,48 @@ const partners = [
"/images/partner-6.png",
];
export default function Partners() {
export default async function Partners() {
const slot = await getStaticImageSlot("brands");
const slotImages = slot?.images ?? [];
return (
<section className="py-14 border-y border-black/5">
<Container>
<div className="flex flex-wrap items-center justify-around gap-x-14 gap-y-8 grayscale opacity-70">
{partners.map((src, i) => (
<Image
key={i}
src={src}
alt="نماد اعتماد"
width={95}
height={82}
className="h-20 sm:h-24 w-auto object-contain"
/>
))}
{slotImages.length > 0
? slotImages.map((item) => {
const logo = (
<Image
src={item.url}
alt={item.titleFa || "نماد اعتماد"}
width={item.width || 95}
height={item.height || 82}
className="h-20 sm:h-24 w-auto object-contain"
/>
);
return item.linkUrl ? (
<a
key={item.id}
href={item.linkUrl}
target="_blank"
rel="noopener noreferrer"
>
{logo}
</a>
) : (
<span key={item.id}>{logo}</span>
);
})
: localPartners.map((src, i) => (
<Image
key={i}
src={src}
alt="نماد اعتماد"
width={95}
height={82}
className="h-20 sm:h-24 w-auto object-contain"
/>
))}
</div>
</Container>
</section>
+2 -25
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import Container from "./Container";
import ProductCard from "./ProductCard";
import ProductsCarousel from "./ProductsCarousel";
import { getProducts } from "@/lib/meshkee";
export default async function Products() {
@@ -11,29 +10,7 @@ export default async function Products() {
return (
<section id="products" className="py-16 sm:py-20">
<Container>
<div className="flex items-end justify-between flex-wrap gap-4">
<h2 className="text-3xl sm:text-4xl font-extrabold text-primary">
محصولات جدید
</h2>
<Link
href="/products"
className="flex items-center gap-2 text-primary font-semibold hover:text-primary-dark transition-colors"
>
مشاهده بیشتر
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="rotate-180">
<path
d="M13.3 5.3a1 1 0 0 1 1.4 0l6 6a1 1 0 0 1 0 1.4l-6 6a1 1 0 1 1-1.4-1.4L18 12.7H4a1 1 0 1 1 0-2h14L13.3 6.7a1 1 0 0 1 0-1.4Z"
fill="currentColor"
/>
</svg>
</Link>
</div>
<div className="mt-10 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5 sm:gap-6">
{featured.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<ProductsCarousel products={featured} />
</Container>
</section>
);
+89
View File
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { useRef, useState } from "react";
import ProductCard from "./ProductCard";
import type { Product } from "@/lib/meshkee";
const ChevronIcon = ({ flipped }: { flipped?: boolean }) => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
className={flipped ? "" : "rotate-180"}
>
<path
d="M13.3 5.3a1 1 0 0 1 1.4 0l6 6a1 1 0 0 1 0 1.4l-6 6a1 1 0 1 1-1.4-1.4L18 12.7H4a1 1 0 1 1 0-2h14L13.3 6.7a1 1 0 0 1 0-1.4Z"
fill="currentColor"
/>
</svg>
);
export default function ProductsCarousel({ products }: { products: Product[] }) {
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const [index, setIndex] = useState(0);
function goTo(i: number) {
const clamped = Math.max(0, Math.min(products.length - 1, i));
setIndex(clamped);
cardRefs.current[clamped]?.scrollIntoView({
behavior: "smooth",
inline: "start",
block: "nearest",
});
}
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"
>
<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>
</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">
{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>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
export default function ScrollReveal({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
style={{ transitionDelay: visible ? `${delay}ms` : "0ms" }}
className={`transition-all duration-700 ease-out ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
} ${className}`}
>
{children}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
const ICONS: Record<string, React.ReactNode> = {
پرمیکس: (
<path
d="M9 2h6v5.5l4.3 8.6A2 2 0 0 1 17.5 19h-11a2 2 0 0 1-1.8-2.9L9 7.5V2Zm2 2v4h2V4h-2Zm-1.4 6-1.6 3.2h8l-1.6-3.2H9.6Z"
fill="currentColor"
/>
),
مکمل: (
<path
d="M7.8 3.8a5 5 0 0 1 7.1 0l5.3 5.3a5 5 0 0 1-7.1 7.1L7.8 11a5 5 0 0 1 0-7.2Zm1.4 1.4a3 3 0 0 0 0 4.2l1.4 1.4 4.2-4.2-1.4-1.4a3 3 0 0 0-4.2 0Z"
fill="currentColor"
/>
),
کنستانتره: (
<path
d="M8 2h8l1 4-2 1v13a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V7l-2-1 1-4Zm2 7v10h4V9h-4Z"
fill="currentColor"
/>
),
گندم: (
<path
d="M12 2c1 2 1 3.5 0 5-1-1.5-1-3 0-5Zm-3 3c1.4 1.4 1.4 3.2 0 4.6C7.6 8.2 7.6 6.4 9 5Zm6 0c1.4 1.4 1.4 3.2 0 4.6C13.6 8.2 13.6 6.4 15 5Zm-3 3c1.4 1.6 1.4 3.4 0 5-1.4-1.6-1.4-3.4 0-5Zm-4 4c1.4 1.4 1.4 3.2 0 4.6C6.6 15.2 6.6 13.4 8 12Zm8 0c1.4 1.4 1.4 3.2 0 4.6-1.4-1.4-1.4-3.2 0-4.6Zm-4 2c1 2 1 4 0 6-1-2-1-4 0-6Z"
fill="currentColor"
/>
),
برنج: (
<path
d="M4 13c0-4.4 3.6-8 8-8s8 3.6 8 8v1a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4v-1Zm4 3h8v-3a4 4 0 0 0-8 0v3Z"
fill="currentColor"
/>
),
سبوس: (
<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"
/>
),
آبزیان: (
<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"
/>
),
طیور: (
<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"
/>
),
دام: (
<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"
/>
),
زنبور: (
<path
d="M12 2a3 3 0 0 1 3 3v1.2A5 5 0 0 1 17 10v3a5 5 0 0 1-10 0v-3a5 5 0 0 1 2-4.8V5a3 3 0 0 1 3-3Zm-3 8h6v1H9v-1Zm0 2.5h6v1H9v-1ZM5 10l-2.5-1.5M19 10l2.5-1.5"
stroke="currentColor"
strokeWidth="1.4"
fill="none"
strokeLinecap="round"
/>
),
اسیدیفایر: (
<path
d="M12 2 6 12.5A6 6 0 1 0 18 12.5L12 2Zm0 4.4 3.8 6.7a3.8 3.8 0 1 1-7.6 0L12 6.4Z"
fill="currentColor"
/>
),
توکسین: (
<path
d="M12 2 3 6v6c0 5 3.8 8.7 9 10 5.2-1.3 9-5 9-10V6l-9-4Zm0 2.2 7 3.1v4.7c0 3.9-2.9 6.8-7 7.9-4.1-1.1-7-4-7-7.9V7.3l7-3.1Zm-1 4.3v5h2v-5h-2Zm0 6.5v2h2v-2h-2Z"
fill="currentColor"
/>
),
آنزیم: (
<path
d="M12 2a5 5 0 0 1 5 5c0 1.6-.8 3-2 3.9V13h2a5 5 0 1 1-5 5v-2a3 3 0 1 0 3-3h-2v-2.1A5 5 0 0 1 7 7a5 5 0 0 1 5-5Zm0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"
fill="currentColor"
/>
),
};
const DEFAULT_ICON = (
<path
d="M12 2c5 2 8 5 8 10a8 8 0 0 1-16 0c0-5 3-8 8-10Zm0 4c-2.5 1.5-4 3.5-4 6a4 4 0 0 0 8 0c0-2.5-1.5-4.5-4-6Z"
fill="currentColor"
/>
);
export function categoryIcon(name: string) {
const key = Object.keys(ICONS).find((k) => name.includes(k));
return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
{key ? ICONS[key] : DEFAULT_ICON}
</svg>
);
}
/** Curated photo per category name for the homepage category showcase. */
const CATEGORY_IMAGES: Record<string, string> = {
کنستانتره: "/images/category-photo-concentrate.jpg",
پرمیکس: "/images/category-photo-premix.jpg",
مکمل: "/images/category-photo-supplement.jpg",
گندم: "/images/category-photo-wheat.jpg",
برنج: "/images/category-photo-rice.jpg",
سبوس: "/images/category-photo-rice.jpg",
دام: "/images/category-photo-concentrate.jpg",
طیور: "/images/category-photo-supplement.jpg",
};
export function categoryFallbackImage(name: string): string {
const key = Object.keys(CATEGORY_IMAGES).find((k) => name.includes(k));
return key ? CATEGORY_IMAGES[key] : "/images/category-photo-supplement.jpg";
}
+114
View File
@@ -3,6 +3,62 @@ import { toFa } from "@/lib/format";
const API_BASE = "https://api.meshkee.com/api/v1";
export const TENANT_DOMAIN = "aradarisman.com";
/**
* Business-managed image overrides (hero slider, 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.
*/
export type StaticImageItem = {
id: string;
url: string;
title: string | null;
titleFa: string | null;
titleEn: string | null;
subtext: string | null;
linkUrl: string | null;
width: number | null;
height: number | null;
sortOrder: number;
};
export type StaticImageSlot = {
id: string;
key: string;
kind: "single" | "list";
label: string;
aspectRatio: string | null;
recommendedWidth: number | null;
itemCount: number | null;
pageKey: string;
images: StaticImageItem[];
};
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
try {
const qs = pageKey ? `?pageKey=${encodeURIComponent(pageKey)}` : "";
const data = await meshkeeFetch<{ items: StaticImageSlot[] }>(
`/tenants/${TENANT_DOMAIN}/website/static-images${qs}`
);
return data.items;
} catch {
return [];
}
}
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
const slots = await getStaticImageSlots();
return slots.find((s) => s.key === key) ?? null;
}
/** "21:9" -> "21 / 9" for CSS aspect-ratio; stays in sync with whatever the API declares. */
export function aspectRatioToCss(ratio: string | null | undefined, fallback = "16 / 9"): string {
if (!ratio) return fallback;
const [w, h] = ratio.split(":");
if (!w || !h) return fallback;
return `${w} / ${h}`;
}
export type ProductStore = {
variantCount: number;
minPrice: number | null;
@@ -94,6 +150,64 @@ export function productPriceLabel(product: Product): string {
return "تماس بگیرید";
}
export type CategorySummary = {
categoryId: string;
name: string;
count: number;
image: string | null;
};
/**
* The backend's raw category taxonomy has duplicate entries sharing the same
* display name under different ids (a data-entry artifact, not a frontend
* bug). This derives category tiles from the products actually published,
* merging duplicate-name ids into one tile (summed count) and linking to
* whichever id holds the most products under that name. `image` is the
* first photographed product found under that name (any of the merged
* ids) — null if none of them have a real photo yet.
*/
export async function getCategorySummary(limit = 8): Promise<CategorySummary[]> {
const { items } = await getProducts({ pageSize: 100 });
const perId = new Map<
string,
{ id: string; name: string; count: number; image: string | null }
>();
for (const p of items) {
if (!p.categoryId || !p.categoryNameFa) continue;
const img = productImage(p);
const entry = perId.get(p.categoryId);
if (entry) {
entry.count += 1;
if (!entry.image && img) entry.image = img;
} else {
perId.set(p.categoryId, { id: p.categoryId, name: p.categoryNameFa, count: 1, image: img });
}
}
const perName = new Map<string, CategorySummary>();
for (const entry of perId.values()) {
const existing = perName.get(entry.name);
if (!existing) {
perName.set(entry.name, {
categoryId: entry.id,
name: entry.name,
count: entry.count,
image: entry.image,
});
continue;
}
existing.count += entry.count;
const currentBest = perId.get(existing.categoryId)?.count ?? 0;
if (entry.count > currentBest) existing.categoryId = entry.id;
if (!existing.image && entry.image) existing.image = entry.image;
}
return Array.from(perName.values())
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
export function stripHtml(html: string, maxLength = 140): string {
const text = html
.replace(/<[^>]*>/g, " ")