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:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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 } : {}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)}`}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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, " ")
|
||||
|
||||
Reference in New Issue
Block a user