Wire homepage images to Meshkee slots and publish the catalog.

The dashboard can refresh these keys from the live site.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-17 09:55:05 +03:30
co-authored by Cursor
parent 1272977923
commit 1c2d1eb31c
9 changed files with 364 additions and 97 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ["sanihome.com", "localhost"],
};
export default nextConfig;
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/meshkee";
export function GET() {
return NextResponse.json(
{ slots: STATIC_IMAGE_SLOT_CATALOG },
{
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=60",
},
},
);
}
+17 -5
View File
@@ -11,18 +11,30 @@ import { SamsungCarousel } from "@/components/home/SamsungCarousel";
import { AboutSanihome } from "@/components/home/AboutSanihome";
import { BlogCarousel } from "@/components/home/BlogCarousel";
import { Footer } from "@/components/home/Footer";
import {
STATIC_IMAGE_KEYS,
firstImageForSlot,
getStaticImageSlots,
imagesForSlot,
} from "@/lib/meshkee";
export default async function Home() {
const slots = await getStaticImageSlots();
export default function Home() {
return (
<>
<TopBar />
<Header />
<main>
<HeroSlider />
<SpecialSaleCarousel />
<HeroSlider images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)} />
<SpecialSaleCarousel
banner={firstImageForSlot(slots, STATIC_IMAGE_KEYS.bannerInSpecialSale)}
/>
<FeatureBar />
<FeaturedCategories />
<TwoBanners />
<FeaturedCategories
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
/>
<TwoBanners images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)} />
<TopSellingCarousel />
<ThreeBanners />
<SamsungCarousel />
+48 -22
View File
@@ -1,8 +1,14 @@
import { Placeholder } from "@/components/ui/Placeholder";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { SlotImage } from "@/components/ui/SlotImage";
import { categories } from "@/data/sample";
import type { StaticImageWithUrl } from "@/lib/meshkee";
export function FeaturedCategories() {
type FeaturedCategoriesProps = {
images?: StaticImageWithUrl[];
};
export function FeaturedCategories({ images = [] }: FeaturedCategoriesProps) {
return (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
@@ -12,27 +18,47 @@ export function FeaturedCategories() {
action="همه دسته‌ها"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-8">
{categories.map((cat, i) => (
<a
key={cat.id}
href="#"
className="animate-slide-in group flex flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
style={{ animationDelay: `${i * 60}ms` }}
>
<Placeholder
src={cat.image}
alt={cat.name}
width="72px"
height="72px"
label="72×72"
className="mb-3 rounded-full transition-colors group-hover:border-red"
/>
<span className="text-center text-xs font-medium leading-relaxed md:text-sm">
{cat.name}
</span>
<span className="mt-1 text-[11px] text-muted">{cat.count} محصول</span>
</a>
))}
{images.length > 0
? images.map((image, i) => (
<div
key={image.id}
className="animate-slide-in group flex flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
style={{ animationDelay: `${i * 60}ms` }}
>
<SlotImage
url={image.url}
title={image.title}
linkUrl={image.linkUrl}
imgClassName="mb-3 h-[72px] w-[72px] rounded-full object-cover transition-colors group-hover:border-red"
/>
{image.title ? (
<span className="text-center text-xs font-medium leading-relaxed md:text-sm">
{image.title}
</span>
) : null}
</div>
))
: categories.map((cat, i) => (
<a
key={cat.id}
href="#"
className="animate-slide-in group flex flex-col items-center rounded-xl border border-border bg-surface p-3 transition-all hover:border-red hover:shadow-md"
style={{ animationDelay: `${i * 60}ms` }}
>
<Placeholder
src={cat.image}
alt={cat.name}
width="72px"
height="72px"
label="72×72"
className="mb-3 rounded-full transition-colors group-hover:border-red"
/>
<span className="text-center text-xs font-medium leading-relaxed md:text-sm">
{cat.name}
</span>
<span className="mt-1 text-[11px] text-muted">{cat.count} محصول</span>
</a>
))}
</div>
</div>
</section>
+53 -21
View File
@@ -2,15 +2,34 @@
import { useState } from "react";
import { Placeholder } from "@/components/ui/Placeholder";
import { SlotImage } from "@/components/ui/SlotImage";
import { sliderSlides } from "@/data/sample";
import type { StaticImageWithUrl } from "@/lib/meshkee";
export function HeroSlider() {
const [active, setActive] = useState(1);
type HeroSliderProps = {
images?: StaticImageWithUrl[];
};
const getIndex = (offset: number) => {
const len = sliderSlides.length;
return (active + offset + len) % len;
};
export function HeroSlider({ images = [] }: HeroSliderProps) {
const remote = images.length > 0;
const slides = remote
? images.map((image) => ({
id: image.id,
title: image.title ?? "",
src: image.url,
linkUrl: image.linkUrl,
}))
: sliderSlides.map((slide) => ({
id: String(slide.id),
title: slide.title,
src: slide.image,
linkUrl: undefined as string | null | undefined,
}));
const [active, setActive] = useState(0);
const len = slides.length;
const getIndex = (offset: number) => (active + offset + len) % len;
const positions = [
{ offset: -1, scale: "scale-[0.92]", opacity: "opacity-55", z: "z-10" },
@@ -24,7 +43,30 @@ export function HeroSlider() {
<div className="relative h-[220px] sm:h-[280px] md:h-[340px] lg:h-[380px]">
{positions.map(({ offset, scale, opacity, z }) => {
const isCenter = offset === 0;
const slide = sliderSlides[getIndex(offset)];
if (!isCenter && len < 2) return null;
const slide = slides[getIndex(offset)];
const heightClass = `w-full h-[200px] sm:h-[260px] md:h-[310px] ${
isCenter ? "lg:h-[380px]" : "lg:h-[350px]"
}`;
const media = remote ? (
<SlotImage
url={slide.src}
title={slide.title}
linkUrl={isCenter ? slide.linkUrl : undefined}
className="block h-full"
imgClassName={`${heightClass} object-cover`}
/>
) : (
<Placeholder
src={slide.src}
alt={slide.title}
width="100%"
label={isCenter ? "1280×380" : "880×350"}
className={`rounded-none border-0 ${heightClass}`}
/>
);
return (
<div
@@ -38,15 +80,7 @@ export function HeroSlider() {
} ${isCenter ? "" : "pointer-events-none"}`}
>
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-lg">
<Placeholder
src={slide.image}
alt={slide.title}
width="100%"
label={isCenter ? "1280×380" : "880×350"}
className={`w-full rounded-none border-0 h-[200px] sm:h-[260px] md:h-[310px] ${
isCenter ? "lg:h-[380px]" : "lg:h-[350px]"
}`}
/>
{media}
</div>
</div>
);
@@ -56,16 +90,14 @@ export function HeroSlider() {
<div className="mt-5 flex items-center justify-center gap-3">
<button
type="button"
onClick={() =>
setActive((prev) => (prev - 1 + sliderSlides.length) % sliderSlides.length)
}
onClick={() => setActive((prev) => (prev - 1 + len) % len)}
className="flex h-9 w-9 items-center justify-center rounded-full border border-border text-red transition-colors hover:border-red hover:bg-red-light"
aria-label="اسلاید قبلی"
>
→
</button>
<div className="flex gap-2">
{sliderSlides.map((slide, i) => (
{slides.map((slide, i) => (
<button
key={slide.id}
type="button"
@@ -79,7 +111,7 @@ export function HeroSlider() {
</div>
<button
type="button"
onClick={() => setActive((prev) => (prev + 1) % sliderSlides.length)}
onClick={() => setActive((prev) => (prev + 1) % len)}
className="flex h-9 w-9 items-center justify-center rounded-full border border-border text-red transition-colors hover:border-red hover:bg-red-light"
aria-label="اسلاید بعدی"
>
+28 -7
View File
@@ -2,14 +2,35 @@ import { Carousel } from "@/components/ui/Carousel";
import { Placeholder } from "@/components/ui/Placeholder";
import { ProductCard } from "@/components/ui/ProductCard";
import { SectionTitle } from "@/components/ui/SectionTitle";
import { SlotImage } from "@/components/ui/SlotImage";
import { specialSaleProducts } from "@/data/sample";
import type { StaticImageWithUrl } from "@/lib/meshkee";
type SpecialSaleCarouselProps = {
banner?: StaticImageWithUrl;
};
function SpecialSaleBanner({ banner }: { banner?: StaticImageWithUrl }) {
const frameClassName =
"relative w-[220px] shrink-0 self-stretch overflow-hidden rounded-xl border border-border md:w-[240px]";
if (banner) {
return (
<div className={frameClassName}>
<SlotImage
url={banner.url}
title={banner.title}
linkUrl={banner.linkUrl}
alt={banner.title ?? "فروش ویژه"}
className="block h-full"
imgClassName="h-full min-h-[380px] w-full object-cover"
/>
</div>
);
}
function SpecialSaleBanner() {
return (
<a
href="#"
className="relative w-[220px] shrink-0 self-stretch overflow-hidden rounded-xl border border-border md:w-[240px]"
>
<a href="#" className={frameClassName}>
<Placeholder
src="/images/sale-banner.svg"
alt="فروش ویژه"
@@ -21,7 +42,7 @@ function SpecialSaleBanner() {
);
}
export function SpecialSaleCarousel() {
export function SpecialSaleCarousel({ banner }: SpecialSaleCarouselProps) {
return (
<section className="border-y border-red-light bg-red-light/30 py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
@@ -31,7 +52,7 @@ export function SpecialSaleCarousel() {
action="مشاهده همه"
/>
<Carousel ariaLabel="کاروسل فروش ویژه">
<SpecialSaleBanner />
<SpecialSaleBanner banner={banner} />
{specialSaleProducts.map((product) => (
<ProductCard
key={product.id}
+62 -41
View File
@@ -1,50 +1,71 @@
import { Placeholder } from "@/components/ui/Placeholder";
import { SlotImage } from "@/components/ui/SlotImage";
import type { StaticImageWithUrl } from "@/lib/meshkee";
export function TwoBanners() {
type TwoBannersProps = {
images?: StaticImageWithUrl[];
};
const fallbackBanners = [
{
src: "/images/banner-1.svg",
alt: "تخفیف ویژه یخچال و فریزر",
kicker: "Summer Sale",
title: "تخفیف ویژه یخچال و فریزر",
subtitle: "تا ۲۵٪ تخفیف — تا پایان مرداد",
},
{
src: "/images/banner-2.svg",
alt: "تلویزیون‌های هوشمند ۲۰۲۶",
kicker: "Smart TV",
title: "تلویزیون‌های هوشمند ۲۰۲۶",
subtitle: "QLED و OLED با اقساط ۱۲ ماهه",
},
];
export function TwoBanners({ images = [] }: TwoBannersProps) {
return (
<section className="py-10 md:py-14">
<div className="mx-auto max-w-7xl px-4">
<div className="grid gap-4 md:grid-cols-2">
<a
href="#"
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
>
<Placeholder
src="/images/banner-1.svg"
alt="تخفیف ویژه یخچال و فریزر"
width="100%"
height="200px"
label="640×200"
className="w-full rounded-none border-0"
/>
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-red-dark/80 to-transparent p-6">
<p className="font-display text-xs tracking-widest text-red-muted uppercase">
Summer Sale
</p>
<h3 className="text-lg font-bold text-white">تخفیف ویژه یخچال و فریزر</h3>
<p className="mt-1 text-sm text-white/80">تا ۲۵٪ تخفیف — تا پایان مرداد</p>
</div>
</a>
<a
href="#"
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
>
<Placeholder
src="/images/banner-2.svg"
alt="تلویزیون‌های هوشمند ۲۰۲۶"
width="100%"
height="200px"
label="640×200"
className="w-full rounded-none border-0"
/>
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-red-dark/80 to-transparent p-6">
<p className="font-display text-xs tracking-widest text-red-muted uppercase">
Smart TV
</p>
<h3 className="text-lg font-bold text-white">تلویزیون‌های هوشمند ۲۰۲۶</h3>
<p className="mt-1 text-sm text-white/80">QLED و OLED با اقساط ۱۲ ماهه</p>
</div>
</a>
{images.length > 0
? images.map((image) => (
<div
key={image.id}
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
>
<SlotImage
url={image.url}
title={image.title}
linkUrl={image.linkUrl}
className="block"
imgClassName="block h-[200px] w-full object-cover"
/>
</div>
))
: fallbackBanners.map((banner) => (
<a
key={banner.src}
href="#"
className="group relative overflow-hidden rounded-2xl border border-border bg-surface transition-shadow hover:shadow-lg"
>
<Placeholder
src={banner.src}
alt={banner.alt}
width="100%"
height="200px"
label="640×200"
className="w-full rounded-none border-0"
/>
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-red-dark/80 to-transparent p-6">
<p className="font-display text-xs tracking-widest text-red-muted uppercase">
{banner.kicker}
</p>
<h3 className="text-lg font-bold text-white">{banner.title}</h3>
<p className="mt-1 text-sm text-white/80">{banner.subtitle}</p>
</div>
</a>
))}
</div>
</div>
</section>
+37
View File
@@ -0,0 +1,37 @@
type SlotImageProps = {
url: string;
title?: string | null;
linkUrl?: string | null;
alt?: string;
className?: string;
imgClassName?: string;
};
export function SlotImage({
url,
title,
linkUrl,
alt,
className,
imgClassName = "block h-full w-full object-cover",
}: SlotImageProps) {
const img = (
// Meshkee slot URLs are used as-is; do not route through next/image.
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={alt ?? title ?? ""} className={imgClassName} />
);
if (linkUrl) {
return (
<a href={linkUrl} className={className}>
{img}
</a>
);
}
if (className) {
return <div className={className}>{img}</div>;
}
return img;
}
+104
View File
@@ -0,0 +1,104 @@
export type StaticImage = {
id: string;
url?: string | null;
title?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number;
};
export type StaticImageWithUrl = StaticImage & { url: string };
export type StaticImageSlot = {
id: string;
key: string;
kind: "single" | "list";
label?: string;
aspectRatio?: string;
recommendedWidth?: number;
sortOrder?: number;
images?: StaticImage[];
};
export const STATIC_IMAGE_KEYS = {
slider: "slider",
bannerInSpecialSale: "banner-in-special-sale",
categories: "categories",
twoBannerRow: "two-banner-row",
} as const;
export const STATIC_IMAGE_SLOT_CATALOG = [
{
key: STATIC_IMAGE_KEYS.slider,
label: "Homepage slider",
kind: "list" as const,
pageKey: "home",
aspectRatio: "16:9",
itemCount: null,
recommendedWidth: 1440,
},
{
key: STATIC_IMAGE_KEYS.bannerInSpecialSale,
label: "Banner in special sale",
kind: "single" as const,
pageKey: "home",
aspectRatio: "12:19",
itemCount: 1,
recommendedWidth: 480,
},
{
key: STATIC_IMAGE_KEYS.categories,
label: "Featured category images",
kind: "list" as const,
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 400,
},
{
key: STATIC_IMAGE_KEYS.twoBannerRow,
label: "Two banner row",
kind: "list" as const,
pageKey: "home",
aspectRatio: "16:5",
itemCount: 2,
recommendedWidth: 1440,
},
];
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const DOMAIN = process.env.NEXT_PUBLIC_DOMAIN ?? "";
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
if (!API_BASE || !DOMAIN) return [];
try {
const res = await fetch(
`${API_BASE}/tenants/${DOMAIN}/website/static-images`,
{ cache: "no-store" },
);
if (!res.ok) return [];
const data: { items?: StaticImageSlot[] } = await res.json();
return data.items ?? [];
} catch {
return [];
}
}
export function imagesForSlot(
slots: StaticImageSlot[],
key: string,
): StaticImageWithUrl[] {
const images = slots.find((slot) => slot.key === key)?.images ?? [];
return [...images]
.filter((image): image is StaticImageWithUrl => Boolean(image.url))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
export function firstImageForSlot(
slots: StaticImageSlot[],
key: string,
): StaticImageWithUrl | undefined {
return imagesForSlot(slots, key)[0];
}