diff --git a/.gitignore b/.gitignore index 7be1c20..4590425 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..970aa52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/public/images/category-photo-concentrate.jpg b/public/images/category-photo-concentrate.jpg new file mode 100644 index 0000000..d6b4e3f Binary files /dev/null and b/public/images/category-photo-concentrate.jpg differ diff --git a/public/images/category-photo-premix.jpg b/public/images/category-photo-premix.jpg new file mode 100644 index 0000000..32f1ac8 Binary files /dev/null and b/public/images/category-photo-premix.jpg differ diff --git a/public/images/category-photo-rice.jpg b/public/images/category-photo-rice.jpg new file mode 100644 index 0000000..99217dd Binary files /dev/null and b/public/images/category-photo-rice.jpg differ diff --git a/public/images/category-photo-supplement.jpg b/public/images/category-photo-supplement.jpg new file mode 100644 index 0000000..5e86d58 Binary files /dev/null and b/public/images/category-photo-supplement.jpg differ diff --git a/public/images/category-photo-wheat.jpg b/public/images/category-photo-wheat.jpg new file mode 100644 index 0000000..6d72f1a Binary files /dev/null and b/public/images/category-photo-wheat.jpg differ diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f34fac..f06d519 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <> - + + - diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index b22d1ef..2d2b8c5 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -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({
+ {categoryId && ( +
+ + فیلتر شده بر اساس دسته‌بندی:{" "} + + {activeCategoryName || "دسته‌بندی انتخابی"} + + + + حذف فیلتر + +
+ )} + {items.length > 0 ? ( <>
@@ -51,6 +71,7 @@ export default async function ProductsPage({ currentPage={currentPage} totalPages={totalPages} basePath="/products" + extraParams={categoryId ? { categoryId } : {}} /> ) : ( diff --git a/src/components/About.tsx b/src/components/About.tsx index dae8aa3..d9bbd8c 100644 --- a/src/components/About.tsx +++ b/src/components/About.tsx @@ -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 (
محصولات آراد آریسمان
diff --git a/src/components/Categories.tsx b/src/components/Categories.tsx deleted file mode 100644 index 8e8706c..0000000 --- a/src/components/Categories.tsx +++ /dev/null @@ -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 ( -
- -
- آراد آریسمان -

- تغذیه مطمئن، پرورش موفق -

-

- دسته‌بندی کامل محصولات ما برای طیور، دام و زنبور عسل -

-
- -
- {categories.map((c) => ( - - {c.title} - - -
- ); -} diff --git a/src/components/CategoryShowcase.tsx b/src/components/CategoryShowcase.tsx new file mode 100644 index 0000000..6ffed83 --- /dev/null +++ b/src/components/CategoryShowcase.tsx @@ -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 ( +
+ + +
+ دسته‌بندی محصولات +

+ محصول مورد نظر خود را پیدا کنید +

+
+ +
+ {tiles.map((tile, i) => ( + + + {tile.title +
+
+ {tile.title && ( + + {tile.title} + + )} + {tile.subtitle && ( + + {tile.subtitle} + + )} +
+ + + ))} +
+
+ +
+ ); +} diff --git a/src/components/Features.tsx b/src/components/Features.tsx index 90d4ba3..5cafa4b 100644 --- a/src/components/Features.tsx +++ b/src/components/Features.tsx @@ -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 (
- {features.map((f) => ( + {items.map((f) => (
(() => { + 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 | 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) => ( -
- {slide.title} -
+ {slides.map((slide, i) => { + const hasCopy = Boolean(slide.title || slide.eyebrow || slide.subtitle); + return ( +
+ {slide.linkUrl ? ( + + {slide.title + + ) : ( + {slide.title + )} -
-
-
- - {slide.eyebrow} - -

- {slide.title} -

-

- {slide.subtitle} -

-
- - مشاهده محصولات - - - تماس با ما - + {hasCopy && ( + <> +
+ +
+
+
+ {slide.eyebrow && ( + + {slide.eyebrow} + + )} + {slide.title && ( +

+ {slide.title} +

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

+ {slide.subtitle} +

+ )} + {!slide.linkUrl && ( +
+ + مشاهده محصولات + + + تماس با ما + +
+ )} +
+
-
-
+ + )}
-
- ))} + ); + })} + +
+
+
+ +
+ {products.map((product, i) => ( +
{ + cardRefs.current[i] = el; + }} + className="w-[70%] sm:w-[45%] lg:w-[23.5%] shrink-0 snap-start" + > + +
+ ))} +
+
+ ); +} diff --git a/src/components/ScrollReveal.tsx b/src/components/ScrollReveal.tsx new file mode 100644 index 0000000..334e24e --- /dev/null +++ b/src/components/ScrollReveal.tsx @@ -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(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 ( +
+ {children} +
+ ); +} diff --git a/src/lib/categoryIcons.tsx b/src/lib/categoryIcons.tsx new file mode 100644 index 0000000..6f5fad9 --- /dev/null +++ b/src/lib/categoryIcons.tsx @@ -0,0 +1,116 @@ +const ICONS: Record = { + پرمیکس: ( + + ), + مکمل: ( + + ), + کنستانتره: ( + + ), + گندم: ( + + ), + برنج: ( + + ), + سبوس: ( + + ), + آبزیان: ( + + ), + طیور: ( + + ), + دام: ( + + ), + زنبور: ( + + ), + اسیدیفایر: ( + + ), + توکسین: ( + + ), + آنزیم: ( + + ), +}; + +const DEFAULT_ICON = ( + +); + +export function categoryIcon(name: string) { + const key = Object.keys(ICONS).find((k) => name.includes(k)); + return ( + + {key ? ICONS[key] : DEFAULT_ICON} + + ); +} + +/** Curated photo per category name for the homepage category showcase. */ +const CATEGORY_IMAGES: Record = { + کنستانتره: "/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"; +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 9426296..9534ee8 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -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 { + 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 { + 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 { + 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(); + 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, " ")