feat: refine storefront product and sale sections

This commit is contained in:
amirhosein.ashourloo
2026-09-23 13:43:18 +03:30
parent 69c54d3b17
commit 3af4e04bdd
13 changed files with 263 additions and 122 deletions
+11 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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} />
</> </>
); );
+12 -12
View File
@@ -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>
+42
View File
@@ -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;
}
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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>
);
}
+94 -27
View File
@@ -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>
))} ))}
</> </>
+20 -4
View File
@@ -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>
+10 -70
View File
@@ -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>
); );
} }
+10
View File
@@ -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[]> {