feat: refine storefront product and sale sections
This commit is contained in:
+11
-1
@@ -210,13 +210,23 @@ catalog data. `STATIC_IMAGE_SLOT_CATALOG` in `meshkee.ts` is the source of
|
|||||||
truth for which slots exist; publishing it via
|
truth for which slots exist; publishing it via
|
||||||
`src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`)
|
`src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`)
|
||||||
is how the admin dashboard's "refresh" imports new keys. Current keys:
|
is how the admin dashboard's "refresh" imports new keys. Current keys:
|
||||||
`home-hero`, `two-banner`, `three-banner-row`, `featured-categories`. Adding
|
`home-hero`, `two-banner`, `three-banner-row`, `featured-categories`,
|
||||||
|
`about-us-image`. `about-us-image` is a homepage `single` slot with a `14:9`
|
||||||
|
ratio (the original 560×360 frame). `AboutSanihome` uses its first image,
|
||||||
|
prefers `titleFa`, then `titleEn`, then `subtext` for alt text, wraps the image
|
||||||
|
with `linkUrl` when present, and preserves `/images/about.svg` as the empty/error
|
||||||
|
fallback. Adding
|
||||||
a slot here does **not** make images appear — someone still has to upload
|
a slot here does **not** make images appear — someone still has to upload
|
||||||
them in the dashboard; the code path just needs to fall back to whatever the
|
them in the dashboard; the code path just needs to fall back to whatever the
|
||||||
"current" behavior was before the slot existed when it's empty (see
|
"current" behavior was before the slot existed when it's empty (see
|
||||||
`FeaturedCategories.tsx` for the pattern: real slot images when present,
|
`FeaturedCategories.tsx` for the pattern: real slot images when present,
|
||||||
else the pre-existing UI unchanged).
|
else the pre-existing UI unchanged).
|
||||||
|
|
||||||
|
**Static-image sizing rule:** whenever a slot's rendered image dimensions or
|
||||||
|
aspect ratio change during development, update the matching
|
||||||
|
`STATIC_IMAGE_SLOT_CATALOG` entry (`aspectRatio` and `recommendedWidth`) in the
|
||||||
|
same change so `/meshkee/static-image-slots` stays accurate for the dashboard.
|
||||||
|
|
||||||
## Homepage product carousels — fully dynamic, don't hardcode a name/key
|
## Homepage product carousels — fully dynamic, don't hardcode a name/key
|
||||||
|
|
||||||
`GET /tenants/{domain}/store-specials` returns **every** admin-managed named
|
`GET /tenants/{domain}/store-specials` returns **every** admin-managed named
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 22 KiB |
+1
-1
@@ -30,7 +30,7 @@ const syne = Syne({
|
|||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const businessInfo = await getBusinessInfo();
|
const businessInfo = await getBusinessInfo();
|
||||||
return {
|
return {
|
||||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
title: "صنیع هوم | فروشگاه لوازم خانگی",
|
||||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||||
icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
|
icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-1
@@ -10,6 +10,7 @@ import {
|
|||||||
getCategories,
|
getCategories,
|
||||||
getStoreSpecials,
|
getStoreSpecials,
|
||||||
getStaticImageSlots,
|
getStaticImageSlots,
|
||||||
|
firstImageForSlot,
|
||||||
imagesForSlot,
|
imagesForSlot,
|
||||||
STATIC_IMAGE_KEYS,
|
STATIC_IMAGE_KEYS,
|
||||||
} from "@/lib/meshkee";
|
} from "@/lib/meshkee";
|
||||||
@@ -44,7 +45,12 @@ export default async function Home() {
|
|||||||
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
|
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
|
||||||
/>
|
/>
|
||||||
<FeatureBar />
|
<FeatureBar />
|
||||||
<AboutSanihome />
|
<AboutSanihome
|
||||||
|
image={firstImageForSlot(staticImageSlots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
||||||
|
aspectRatio={staticImageSlots.find(
|
||||||
|
(slot) => slot.key === STATIC_IMAGE_KEYS.aboutUsImage,
|
||||||
|
)?.aspectRatio}
|
||||||
|
/>
|
||||||
<BlogCarousel posts={blogsResult.items} />
|
<BlogCarousel posts={blogsResult.items} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Placeholder } from "@/components/ui/Placeholder";
|
import { AboutSlotImage } from "@/components/home/AboutSlotImage";
|
||||||
|
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||||
|
|
||||||
export function AboutSanihome() {
|
export function AboutSanihome({
|
||||||
|
image,
|
||||||
|
aspectRatio,
|
||||||
|
}: {
|
||||||
|
image?: StaticImageWithUrl;
|
||||||
|
aspectRatio?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="border-y border-red-light bg-gradient-to-br from-red-light/40 via-surface to-background py-12 md:py-16">
|
<section className="border-y border-red-light bg-gradient-to-br from-red-light/40 via-surface to-background py-12 md:py-16">
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
@@ -8,7 +15,7 @@ export function AboutSanihome() {
|
|||||||
<div className="animate-fade-up">
|
<div className="animate-fade-up">
|
||||||
<div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
<h2 className="text-2xl font-bold text-red-dark md:text-3xl">
|
<h2 className="text-2xl font-bold text-red-dark md:text-3xl">
|
||||||
درباره سانیهوم
|
درباره صنیع هوم
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<span
|
||||||
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
className="font-display text-sm font-semibold tracking-wide text-red-dark/30 uppercase md:text-base"
|
||||||
@@ -19,7 +26,7 @@ export function AboutSanihome() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 leading-relaxed text-muted">
|
<p className="mb-4 leading-relaxed text-muted">
|
||||||
سانیهوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
صنیع هوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||||
خانگی در ایران فعالیت میکند. ما با ارائه محصولات اصل از برندهای
|
خانگی در ایران فعالیت میکند. ما با ارائه محصولات اصل از برندهای
|
||||||
معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربهای مطمئن برای
|
معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربهای مطمئن برای
|
||||||
خرید لوازم خانگی فراهم کردهایم.
|
خرید لوازم خانگی فراهم کردهایم.
|
||||||
@@ -43,14 +50,7 @@ export function AboutSanihome() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Placeholder
|
<AboutSlotImage image={image} aspectRatio={aspectRatio} />
|
||||||
src="/images/about.svg"
|
|
||||||
alt="درباره سانیهوم"
|
|
||||||
width="100%"
|
|
||||||
height="360px"
|
|
||||||
label="560×360"
|
|
||||||
className="w-full rounded-2xl"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Placeholder } from "@/components/ui/Placeholder";
|
||||||
|
import type { StaticImageWithUrl } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export function AboutSlotImage({
|
||||||
|
image,
|
||||||
|
aspectRatio,
|
||||||
|
}: {
|
||||||
|
image?: StaticImageWithUrl;
|
||||||
|
aspectRatio?: string;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
if (!image || failed) {
|
||||||
|
return (
|
||||||
|
<Placeholder
|
||||||
|
src="/images/about.svg"
|
||||||
|
alt="درباره صنیع هوم"
|
||||||
|
width="100%"
|
||||||
|
height="360px"
|
||||||
|
label="560×360"
|
||||||
|
className="w-full rounded-2xl"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = (
|
||||||
|
// Meshkee slot URLs are rendered as-is; a failed remote image falls back locally.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.titleFa ?? image.titleEn ?? image.subtext ?? "درباره صنیع هوم"}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
style={{ aspectRatio: (aspectRatio ?? "14:9").replace(":", " / ") }}
|
||||||
|
className="h-auto w-full rounded-2xl object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return image.linkUrl ? <a href={image.linkUrl}>{frame}</a> : frame;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export function Footer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-[#dddddd] bg-[#f1f1f1] text-black">
|
<footer className="border-t border-[#dddddd] bg-[#f1f1f1] text-black">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-12">
|
<div className="mx-auto max-w-7xl px-4 pt-10 pb-3">
|
||||||
<div className="grid gap-8 md:grid-cols-4">
|
<div className="grid gap-8 md:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
@@ -36,7 +36,7 @@ export function Footer({
|
|||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
|
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
|
||||||
alt={businessInfo.nameFa || "سانیهوم"}
|
alt={businessInfo.nameFa?.replace(/سانی[ ]?هوم/g, "صنیع هوم") || "صنیع هوم"}
|
||||||
className="h-[72px] w-auto object-contain"
|
className="h-[72px] w-auto object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -112,7 +112,7 @@ export function Footer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-black/10 pt-6 text-xs text-black/50 md:flex-row">
|
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-black/10 pt-6 text-xs text-black/50 md:flex-row">
|
||||||
<p>© ۱۴۰۵ سانیهوم — تمامی حقوق محفوظ است.</p>
|
<p>© ۱۴۰۵ صنیع هوم — تمامی حقوق محفوظ است.</p>
|
||||||
<a
|
<a
|
||||||
href="https://meshkee.com"
|
href="https://meshkee.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ function Logo({ src, large = false }: { src?: string | null; large?: boolean })
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={src || "/images/logo.png"}
|
src={src || "/images/logo.png"}
|
||||||
alt="سانیهوم"
|
alt="صنیع هوم"
|
||||||
className={
|
className={
|
||||||
large
|
large
|
||||||
? "h-[65px] w-auto object-contain"
|
? "h-[65px] w-auto object-contain"
|
||||||
: "h-[70px] w-auto object-contain md:h-[86px]"
|
: "h-[65px] w-auto object-contain"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@@ -226,7 +226,7 @@ export function Header({
|
|||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-surface/95 backdrop-blur-sm">
|
<header className="sticky top-0 z-50 border-b border-border bg-surface/95 backdrop-blur-sm">
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
<div className="grid grid-cols-3 items-center gap-4 py-4 min-[1200px]:flex min-[1200px]:justify-between">
|
<div className="grid grid-cols-3 items-center gap-4 py-2 min-[1200px]:flex min-[1200px]:justify-between">
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type RemainingTime = {
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRemainingTime(): RemainingTime {
|
||||||
|
const now = new Date();
|
||||||
|
const endOfDay = new Date(now);
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
const totalSeconds = Math.max(0, Math.floor((endOfDay.getTime() - now.getTime()) / 1000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
hours: Math.floor(totalSeconds / 3600),
|
||||||
|
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||||
|
seconds: totalSeconds % 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeBox({ value, label }: { value: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col items-center rounded-xl bg-white/15 px-1 py-2.5 md:py-3">
|
||||||
|
<strong className="text-lg leading-none tabular-nums md:text-2xl" suppressHydrationWarning>
|
||||||
|
{String(value).padStart(2, "0")}
|
||||||
|
</strong>
|
||||||
|
<span className="mt-2 text-[10px] text-white/80 md:text-xs">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialSaleCountdown() {
|
||||||
|
const [remaining, setRemaining] = useState(getRemainingTime);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setRemaining(getRemainingTime()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid w-full grid-cols-3 gap-1.5 md:gap-2" dir="ltr">
|
||||||
|
<TimeBox value={remaining.hours} label="ساعت" />
|
||||||
|
<TimeBox value={remaining.minutes} label="دقیقه" />
|
||||||
|
<TimeBox value={remaining.seconds} label="ثانیه" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import { Fragment } from "react";
|
|||||||
import { Carousel } from "@/components/ui/Carousel";
|
import { Carousel } from "@/components/ui/Carousel";
|
||||||
import { ProductCard } from "@/components/ui/ProductCard";
|
import { ProductCard } from "@/components/ui/ProductCard";
|
||||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||||
|
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||||
import { TwoBanners } from "@/components/home/TwoBanners";
|
import { TwoBanners } from "@/components/home/TwoBanners";
|
||||||
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
||||||
|
import { SpecialSaleCountdown } from "@/components/home/SpecialSaleCountdown";
|
||||||
import { productHref, type ProductGroup, type StaticImageWithUrl } from "@/lib/meshkee";
|
import { productHref, type ProductGroup, type StaticImageWithUrl } from "@/lib/meshkee";
|
||||||
|
|
||||||
// The API only gives one `title` string per group (sometimes already
|
// The API only gives one `title` string per group (sometimes already
|
||||||
@@ -19,6 +21,67 @@ function englishLabelFromKey(key: string): string | undefined {
|
|||||||
return /[a-zA-Z]/.test(label) ? label.toUpperCase() : undefined;
|
return /[a-zA-Z]/.test(label) ? label.toUpperCase() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSpecialSaleGroup(group: ProductGroup): boolean {
|
||||||
|
return /فروش\s*ویژه|special[\s_-]*sale/i.test(`${group.title} ${group.key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpecialSaleSection({ group }: { group: ProductGroup }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-red py-6 md:py-8">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-stretch gap-3 px-4 md:gap-5">
|
||||||
|
<aside className="my-2 flex w-[calc(50%-1rem)] shrink-0 flex-col items-center justify-center rounded-2xl bg-red-dark/35 p-3 text-center text-white md:w-[240px] md:p-6">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
className="mb-3 h-11 w-11 md:h-14 md:w-14"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g transform="matrix(1.05 0 0 1.05 -.8 -.8006)">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m30.25 14.182-.876-.876c-.36-.36-.577-1.168-.445-1.661l.32-1.197a2.54 2.54 0 0 0-.259-1.949 2.55 2.55 0 0 0-1.559-1.198l-1.196-.321c-.492-.132-1.084-.724-1.217-1.215l-.32-1.197C24.34 3.232 22.899 2.39 21.55 2.75l-1.196.321c-.459.122-1.316-.101-1.66-.445l-.877-.876c-.969-.969-2.666-.969-3.635 0l-.877.877c-.344.344-1.201.567-1.661.445l-1.195-.321c-1.349-.361-2.79.481-3.148 1.818l-.32 1.195c-.132.494-.724 1.085-1.216 1.217l-1.197.321c-.662.177-1.216.603-1.56 1.199s-.435 1.288-.257 1.947l.319 1.198c.132.493-.084 1.3-.444 1.661l-.876.876C1.267 14.667 1 15.313 1 16s.267 1.333.751 1.817l.876.876c.36.36.577 1.168.445 1.661l-.32 1.197a2.54 2.54 0 0 0 .259 1.949 2.55 2.55 0 0 0 1.559 1.198l1.195.32c.493.132 1.085.724 1.218 1.215l.319 1.197c.358 1.336 1.804 2.175 3.147 1.817l1.197-.32c.46-.122 1.317.101 1.661.445l.876.875c.484.486 1.13.753 1.818.753s1.334-.267 1.817-.75l.877-.876c.345-.344 1.205-.565 1.661-.445l1.195.32q.326.087.662.087h.001a2.575 2.575 0 0 0 2.485-1.905l.319-1.194c.133-.494.725-1.085 1.217-1.218l1.196-.32c.662-.177 1.216-.604 1.56-1.2s.435-1.288.257-1.947l-.319-1.198c-.132-.493.084-1.3.444-1.661l.876-.876c.484-.484.751-1.13.751-1.817a2.55 2.55 0 0 0-.75-1.818M9 12c0-1.654 1.346-3 3-3s3 1.346 3 3-1.346 3-3 3-3-1.346-3-3m3.707 8.707a.997.997 0 0 1-1.414 0 1 1 0 0 1 0-1.414l8-8a.999.999 0 1 1 1.414 1.414zM20 23c-1.654 0-3-1.346-3-3s1.346-3 3-3 3 1.346 3 3-1.346 3-3 3"
|
||||||
|
/>
|
||||||
|
<circle cx="20" cy="20" r="1" fill="currentColor" />
|
||||||
|
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg font-bold md:text-2xl">{group.title}</h2>
|
||||||
|
<p className="mt-1.5 mb-4 text-xs text-white/80 md:text-sm">تا پایان فروش ویژه</p>
|
||||||
|
<SpecialSaleCountdown />
|
||||||
|
<a
|
||||||
|
href={`/products?specialId=${group.id}`}
|
||||||
|
className="mt-4 flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black transition-colors hover:bg-white/90"
|
||||||
|
>
|
||||||
|
مشاهده همه
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Carousel
|
||||||
|
ariaLabel={`کاروسل ${group.title}`}
|
||||||
|
slideClassName="!w-full md:!w-[240px]"
|
||||||
|
>
|
||||||
|
{group.items.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
name={product.name}
|
||||||
|
brand={product.brand ?? ""}
|
||||||
|
price={product.price}
|
||||||
|
oldPrice={product.oldPrice}
|
||||||
|
discount={product.discount}
|
||||||
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
|
href={productHref(product)}
|
||||||
|
variant="sale"
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Renders every admin-managed carousel from GET /store-specials — whatever
|
// Renders every admin-managed carousel from GET /store-specials — whatever
|
||||||
// groups exist there, in their configured order. Nothing here is keyed off
|
// groups exist there, in their configured order. Nothing here is keyed off
|
||||||
// a specific group id/key/title, so a new carousel created in the Meshkee
|
// a specific group id/key/title, so a new carousel created in the Meshkee
|
||||||
@@ -55,33 +118,37 @@ export function StoreSpecialCarousels({
|
|||||||
{visibleGroups.map((group, i) => (
|
{visibleGroups.map((group, i) => (
|
||||||
<Fragment key={group.id}>
|
<Fragment key={group.id}>
|
||||||
{i > 0 && banners[(i - 1) % banners.length]}
|
{i > 0 && banners[(i - 1) % banners.length]}
|
||||||
<section className="py-10 md:py-14">
|
{isSpecialSaleGroup(group) ? (
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<SpecialSaleSection group={group} />
|
||||||
<SectionTitle
|
) : (
|
||||||
title={group.title}
|
<section className="py-10 md:py-14">
|
||||||
enTitle={englishLabelFromKey(group.key)}
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
action="مشاهده همه"
|
<SectionTitle
|
||||||
actionHref={`/products?specialId=${group.id}`}
|
title={group.title}
|
||||||
/>
|
enTitle={englishLabelFromKey(group.key)}
|
||||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
action="مشاهده همه"
|
||||||
{group.items.map((product) => (
|
actionHref={`/products?specialId=${group.id}`}
|
||||||
<ProductCard
|
/>
|
||||||
key={product.id}
|
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||||
name={product.name}
|
{group.items.map((product) => (
|
||||||
brand={product.brand ?? ""}
|
<ProductCard
|
||||||
price={product.price}
|
key={product.id}
|
||||||
oldPrice={product.oldPrice}
|
name={product.name}
|
||||||
discount={product.discount}
|
brand={product.brand ?? ""}
|
||||||
image={product.image ?? "/images/placeholder.svg"}
|
price={product.price}
|
||||||
href={productHref(product)}
|
oldPrice={product.oldPrice}
|
||||||
contactPhone={contactPhone}
|
discount={product.discount}
|
||||||
variantId={product.variantId}
|
image={product.image ?? "/images/placeholder.svg"}
|
||||||
slug={product.slug}
|
href={productHref(product)}
|
||||||
/>
|
contactPhone={contactPhone}
|
||||||
))}
|
variantId={product.variantId}
|
||||||
</Carousel>
|
slug={product.slug}
|
||||||
</div>
|
/>
|
||||||
</section>
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Children, useRef } from "react";
|
import { Children, useRef, useState } from "react";
|
||||||
import { Swiper, SwiperSlide } from "swiper/react";
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
import { FreeMode } from "swiper/modules";
|
import { FreeMode } from "swiper/modules";
|
||||||
import type { Swiper as SwiperClass } from "swiper";
|
import type { Swiper as SwiperClass } from "swiper";
|
||||||
@@ -10,6 +10,7 @@ import "swiper/css/free-mode";
|
|||||||
type CarouselProps = {
|
type CarouselProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
|
slideClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChevronLeftIcon() {
|
function ChevronLeftIcon() {
|
||||||
@@ -40,11 +41,12 @@ function ChevronRightIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Carousel({ children, ariaLabel }: CarouselProps) {
|
export function Carousel({ children, ariaLabel, slideClassName }: CarouselProps) {
|
||||||
const swiperRef = useRef<SwiperClass | null>(null);
|
const swiperRef = useRef<SwiperClass | null>(null);
|
||||||
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
|
|
||||||
const btnClass =
|
const btnClass =
|
||||||
"absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-white/40 text-red shadow-sm backdrop-blur-md transition-colors hover:border-red/40 hover:bg-white/60 hover:text-red-dark";
|
"absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/40 bg-white/40 text-red shadow-sm backdrop-blur-md transition-colors hover:border-red/40 hover:bg-white/60 hover:text-red-dark disabled:pointer-events-none disabled:opacity-0";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -52,6 +54,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => swiperRef.current?.slidePrev()}
|
onClick={() => swiperRef.current?.slidePrev()}
|
||||||
aria-label="قبلی"
|
aria-label="قبلی"
|
||||||
|
disabled={isLocked}
|
||||||
className={`${btnClass} right-1`}
|
className={`${btnClass} right-1`}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
@@ -60,6 +63,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => swiperRef.current?.slideNext()}
|
onClick={() => swiperRef.current?.slideNext()}
|
||||||
aria-label="بعدی"
|
aria-label="بعدی"
|
||||||
|
disabled={isLocked}
|
||||||
className={`${btnClass} left-1`}
|
className={`${btnClass} left-1`}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
@@ -68,10 +72,17 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
|||||||
modules={[FreeMode]}
|
modules={[FreeMode]}
|
||||||
onSwiper={(swiper) => {
|
onSwiper={(swiper) => {
|
||||||
swiperRef.current = swiper;
|
swiperRef.current = swiper;
|
||||||
|
setIsLocked(swiper.isLocked);
|
||||||
}}
|
}}
|
||||||
|
onLock={(swiper) => {
|
||||||
|
swiper.slideTo(0, 0);
|
||||||
|
setIsLocked(true);
|
||||||
|
}}
|
||||||
|
onUnlock={() => setIsLocked(false)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
slidesPerView="auto"
|
slidesPerView="auto"
|
||||||
spaceBetween={16}
|
spaceBetween={16}
|
||||||
|
watchOverflow
|
||||||
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
||||||
grabCursor
|
grabCursor
|
||||||
role="region"
|
role="region"
|
||||||
@@ -83,7 +94,12 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
|||||||
// wrapper height — resolves per-slide instead of stretching to match
|
// wrapper height — resolves per-slide instead of stretching to match
|
||||||
// the tallest sibling. height:auto (inline, so it wins) lets normal
|
// the tallest sibling. height:auto (inline, so it wins) lets normal
|
||||||
// flex stretch equalize card heights across a row.
|
// flex stretch equalize card heights across a row.
|
||||||
<SwiperSlide style={{ width: "auto", height: "auto" }}>{child}</SwiperSlide>
|
<SwiperSlide
|
||||||
|
className={slideClassName}
|
||||||
|
style={slideClassName ? { height: "auto" } : { width: "auto", height: "auto" }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Placeholder } from "./Placeholder";
|
import { Placeholder } from "./Placeholder";
|
||||||
import { formatPrice } from "@/data/sample";
|
import { formatPrice } from "@/data/sample";
|
||||||
import { useCart } from "@/context/CartContext";
|
|
||||||
|
|
||||||
type ProductCardProps = {
|
type ProductCardProps = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,10 +12,6 @@ type ProductCardProps = {
|
|||||||
href?: string;
|
href?: string;
|
||||||
fluid?: boolean;
|
fluid?: boolean;
|
||||||
contactPhone?: string;
|
contactPhone?: string;
|
||||||
// When known (store-specials/brand-groups/store-items cards already point
|
|
||||||
// at one purchasable variant), the button adds straight to the guest cart.
|
|
||||||
// Otherwise it just links to the detail page, where a variant can be
|
|
||||||
// picked first — never fabricate a variant id to force a quick-add.
|
|
||||||
variantId?: string;
|
variantId?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
};
|
};
|
||||||
@@ -34,27 +26,7 @@ export function ProductCard({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
href,
|
href,
|
||||||
fluid = false,
|
fluid = false,
|
||||||
contactPhone,
|
|
||||||
variantId,
|
|
||||||
slug,
|
|
||||||
}: ProductCardProps) {
|
}: ProductCardProps) {
|
||||||
const { addItem } = useCart();
|
|
||||||
const [status, setStatus] = useState<"idle" | "done">("idle");
|
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
|
||||||
if (!variantId || price == null) return;
|
|
||||||
addItem({
|
|
||||||
id: variantId,
|
|
||||||
name,
|
|
||||||
slug: slug ?? "",
|
|
||||||
price,
|
|
||||||
originalPrice: oldPrice ?? undefined,
|
|
||||||
image,
|
|
||||||
});
|
|
||||||
setStatus("done");
|
|
||||||
setTimeout(() => setStatus("idle"), 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const info = (
|
const info = (
|
||||||
<>
|
<>
|
||||||
<div className="relative p-[10px]">
|
<div className="relative p-[10px]">
|
||||||
@@ -71,19 +43,21 @@ export function ProductCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 pb-0">
|
<div className="px-4 pb-4">
|
||||||
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
{brand && (
|
||||||
{brand}
|
<p className="font-display mb-1 line-clamp-2 h-8 text-[11px] leading-4 tracking-wide text-muted uppercase">
|
||||||
</p>
|
{brand}
|
||||||
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="flex h-12 flex-col items-end justify-end space-y-1 text-left">
|
||||||
{oldPrice != null && (
|
{oldPrice != null && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted line-through">{formatPrice(oldPrice)}</span>
|
<span className="text-sm text-muted line-through">{formatPrice(oldPrice)}</span>
|
||||||
{discount != null && (
|
{discount != null && (
|
||||||
<span className="rounded-md bg-green-50 px-1.5 py-0.5 text-[11px] font-bold text-green-700">
|
<span className="rounded-md bg-red px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||||
{discount}٪
|
{discount}٪
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -115,40 +89,6 @@ export function ProductCard({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1">{info}</div>
|
<div className="flex-1">{info}</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
|
||||||
{price != null && variantId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddToCart}
|
|
||||||
className="w-full rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
|
||||||
>
|
|
||||||
{status === "done" ? "افزوده شد ✓" : "افزودن به سبد"}
|
|
||||||
</button>
|
|
||||||
) : price != null && href ? (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="block w-full rounded-lg bg-red py-2 text-center text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
|
||||||
>
|
|
||||||
افزودن به سبد
|
|
||||||
</a>
|
|
||||||
) : contactPhone ? (
|
|
||||||
<a
|
|
||||||
href={`tel:${contactPhone}`}
|
|
||||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-red py-2 text-sm font-medium text-white transition-colors hover:bg-red-dark"
|
|
||||||
>
|
|
||||||
<span aria-hidden>📞</span>
|
|
||||||
تماس با فروشگاه
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="w-full cursor-not-allowed rounded-lg bg-red/60 py-2 text-sm font-medium text-white"
|
|
||||||
>
|
|
||||||
تماس با فروشگاه
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -583,6 +583,7 @@ export const STATIC_IMAGE_KEYS = {
|
|||||||
twoBanner: "two-banner",
|
twoBanner: "two-banner",
|
||||||
threeBannerRow: "three-banner-row",
|
threeBannerRow: "three-banner-row",
|
||||||
featuredCategories: "featured-categories",
|
featuredCategories: "featured-categories",
|
||||||
|
aboutUsImage: "about-us-image",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||||
@@ -622,6 +623,15 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
|
|||||||
itemCount: null,
|
itemCount: null,
|
||||||
recommendedWidth: 300,
|
recommendedWidth: 300,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: STATIC_IMAGE_KEYS.aboutUsImage,
|
||||||
|
label: "Homepage about-us image",
|
||||||
|
kind: "single" as const,
|
||||||
|
pageKey: "home",
|
||||||
|
aspectRatio: "14:9",
|
||||||
|
itemCount: 1,
|
||||||
|
recommendedWidth: 560,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
|
export async function getStaticImageSlots(): Promise<StaticImageSlot[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user