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
|
||||
`src/app/meshkee/static-image-slots/route.ts` (`/meshkee/static-image-slots`)
|
||||
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
|
||||
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
|
||||
`FeaturedCategories.tsx` for the pattern: real slot images when present,
|
||||
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
|
||||
|
||||
`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> {
|
||||
const businessInfo = await getBusinessInfo();
|
||||
return {
|
||||
title: "سانیهوم | فروشگاه لوازم خانگی",
|
||||
title: "صنیع هوم | فروشگاه لوازم خانگی",
|
||||
description: "فروشگاه آنلاین لوازم خانگی — یخچال، لباسشویی، تلویزیون و بیشتر",
|
||||
icons: businessInfo?.faviconUrl ? [{ url: businessInfo.faviconUrl }] : undefined,
|
||||
};
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ import {
|
||||
getCategories,
|
||||
getStoreSpecials,
|
||||
getStaticImageSlots,
|
||||
firstImageForSlot,
|
||||
imagesForSlot,
|
||||
STATIC_IMAGE_KEYS,
|
||||
} from "@/lib/meshkee";
|
||||
@@ -44,7 +45,12 @@ export default async function Home() {
|
||||
threeBannerAspectRatio={threeBannerSlot?.aspectRatio}
|
||||
/>
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -8,7 +15,7 @@ export function AboutSanihome() {
|
||||
<div className="animate-fade-up">
|
||||
<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>
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
<p className="mb-4 leading-relaxed text-muted">
|
||||
سانیهوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||
صنیع هوم از سال ۱۳۹۰ به عنوان یکی از معتبرترین فروشگاههای آنلاین لوازم
|
||||
خانگی در ایران فعالیت میکند. ما با ارائه محصولات اصل از برندهای
|
||||
معتبر جهانی، گارانتی رسمی و خدمات پس از فروش، تجربهای مطمئن برای
|
||||
خرید لوازم خانگی فراهم کردهایم.
|
||||
@@ -43,14 +50,7 @@ export function AboutSanihome() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Placeholder
|
||||
src="/images/about.svg"
|
||||
alt="درباره سانیهوم"
|
||||
width="100%"
|
||||
height="360px"
|
||||
label="560×360"
|
||||
className="w-full rounded-2xl"
|
||||
/>
|
||||
<AboutSlotImage image={image} aspectRatio={aspectRatio} />
|
||||
</div>
|
||||
</div>
|
||||
</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 (
|
||||
<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>
|
||||
<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
|
||||
<img
|
||||
src={businessInfo.logoDarkUrl ?? businessInfo.logoUrl ?? undefined}
|
||||
alt={businessInfo.nameFa || "سانیهوم"}
|
||||
alt={businessInfo.nameFa?.replace(/سانی[ ]?هوم/g, "صنیع هوم") || "صنیع هوم"}
|
||||
className="h-[72px] w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
@@ -112,7 +112,7 @@ export function Footer({
|
||||
</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">
|
||||
<p>© ۱۴۰۵ سانیهوم — تمامی حقوق محفوظ است.</p>
|
||||
<p>© ۱۴۰۵ صنیع هوم — تمامی حقوق محفوظ است.</p>
|
||||
<a
|
||||
href="https://meshkee.com"
|
||||
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 */}
|
||||
<img
|
||||
src={src || "/images/logo.png"}
|
||||
alt="سانیهوم"
|
||||
alt="صنیع هوم"
|
||||
className={
|
||||
large
|
||||
? "h-[65px] w-auto object-contain"
|
||||
: "h-[70px] w-auto object-contain md:h-[86px]"
|
||||
: "h-[65px] w-auto object-contain"
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
@@ -226,7 +226,7 @@ export function Header({
|
||||
return (
|
||||
<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="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">
|
||||
<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 { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { SectionTitle } from "@/components/ui/SectionTitle";
|
||||
import { ChevronLeftIcon } from "@/components/ui/icons";
|
||||
import { TwoBanners } from "@/components/home/TwoBanners";
|
||||
import { ThreeBanners } from "@/components/home/ThreeBanners";
|
||||
import { SpecialSaleCountdown } from "@/components/home/SpecialSaleCountdown";
|
||||
import { productHref, type ProductGroup, type StaticImageWithUrl } from "@/lib/meshkee";
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -55,33 +118,37 @@ export function StoreSpecialCarousels({
|
||||
{visibleGroups.map((group, i) => (
|
||||
<Fragment key={group.id}>
|
||||
{i > 0 && banners[(i - 1) % banners.length]}
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title={group.title}
|
||||
enTitle={englishLabelFromKey(group.key)}
|
||||
action="مشاهده همه"
|
||||
actionHref={`/products?specialId=${group.id}`}
|
||||
/>
|
||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||
{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)}
|
||||
contactPhone={contactPhone}
|
||||
variantId={product.variantId}
|
||||
slug={product.slug}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
{isSpecialSaleGroup(group) ? (
|
||||
<SpecialSaleSection group={group} />
|
||||
) : (
|
||||
<section className="py-10 md:py-14">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<SectionTitle
|
||||
title={group.title}
|
||||
enTitle={englishLabelFromKey(group.key)}
|
||||
action="مشاهده همه"
|
||||
actionHref={`/products?specialId=${group.id}`}
|
||||
/>
|
||||
<Carousel ariaLabel={`کاروسل ${group.title}`}>
|
||||
{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)}
|
||||
contactPhone={contactPhone}
|
||||
variantId={product.variantId}
|
||||
slug={product.slug}
|
||||
/>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useRef } from "react";
|
||||
import { Children, useRef, useState } from "react";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FreeMode } from "swiper/modules";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
@@ -10,6 +10,7 @@ import "swiper/css/free-mode";
|
||||
type CarouselProps = {
|
||||
children: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
slideClassName?: string;
|
||||
};
|
||||
|
||||
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 [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
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 (
|
||||
<div className="relative">
|
||||
@@ -52,6 +54,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
type="button"
|
||||
onClick={() => swiperRef.current?.slidePrev()}
|
||||
aria-label="قبلی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} right-1`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
@@ -60,6 +63,7 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
type="button"
|
||||
onClick={() => swiperRef.current?.slideNext()}
|
||||
aria-label="بعدی"
|
||||
disabled={isLocked}
|
||||
className={`${btnClass} left-1`}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
@@ -68,10 +72,17 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
modules={[FreeMode]}
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
setIsLocked(swiper.isLocked);
|
||||
}}
|
||||
onLock={(swiper) => {
|
||||
swiper.slideTo(0, 0);
|
||||
setIsLocked(true);
|
||||
}}
|
||||
onUnlock={() => setIsLocked(false)}
|
||||
dir="rtl"
|
||||
slidesPerView="auto"
|
||||
spaceBetween={16}
|
||||
watchOverflow
|
||||
freeMode={{ enabled: true, momentum: true, sticky: true }}
|
||||
grabCursor
|
||||
role="region"
|
||||
@@ -83,7 +94,12 @@ export function Carousel({ children, ariaLabel }: CarouselProps) {
|
||||
// wrapper height — resolves per-slide instead of stretching to match
|
||||
// the tallest sibling. height:auto (inline, so it wins) lets normal
|
||||
// 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>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Placeholder } from "./Placeholder";
|
||||
import { formatPrice } from "@/data/sample";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
type ProductCardProps = {
|
||||
name: string;
|
||||
@@ -16,10 +12,6 @@ type ProductCardProps = {
|
||||
href?: string;
|
||||
fluid?: boolean;
|
||||
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;
|
||||
slug?: string;
|
||||
};
|
||||
@@ -34,27 +26,7 @@ export function ProductCard({
|
||||
variant = "default",
|
||||
href,
|
||||
fluid = false,
|
||||
contactPhone,
|
||||
variantId,
|
||||
slug,
|
||||
}: 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 = (
|
||||
<>
|
||||
<div className="relative p-[10px]">
|
||||
@@ -71,19 +43,21 @@ export function ProductCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 pb-0">
|
||||
<p className="font-display mb-1 text-[11px] tracking-wide text-muted uppercase">
|
||||
{brand}
|
||||
</p>
|
||||
<h3 className="mb-3 line-clamp-2 min-h-[2.75rem] text-sm leading-relaxed font-medium">
|
||||
<div className="px-4 pb-4">
|
||||
{brand && (
|
||||
<p className="font-display mb-1 line-clamp-2 h-8 text-[11px] leading-4 tracking-wide text-muted uppercase">
|
||||
{brand}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="mb-2 line-clamp-2 h-11 text-sm leading-6 font-medium">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-12 flex-col items-end justify-end space-y-1 text-left">
|
||||
{oldPrice != null && (
|
||||
<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 && (
|
||||
<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}٪
|
||||
</span>
|
||||
)}
|
||||
@@ -115,40 +89,6 @@ export function ProductCard({
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -583,6 +583,7 @@ export const STATIC_IMAGE_KEYS = {
|
||||
twoBanner: "two-banner",
|
||||
threeBannerRow: "three-banner-row",
|
||||
featuredCategories: "featured-categories",
|
||||
aboutUsImage: "about-us-image",
|
||||
} as const;
|
||||
|
||||
export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
@@ -622,6 +623,15 @@ export const STATIC_IMAGE_SLOT_CATALOG = [
|
||||
itemCount: null,
|
||||
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[]> {
|
||||
|
||||
Reference in New Issue
Block a user