From 051939c6e2a7548908e3a7734a944325c0231448 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 18 Aug 2026 16:19:29 +0330 Subject: [PATCH] Tighten storefront cards and rebuild the product page. Match carousel cards on XL, add gallery zoom/lightbox, quantity and comments, and update the sale banner slot to 2:3. Co-authored-by: Cursor --- src/app/globals.css | 54 +++++++++++ src/app/page.tsx | 1 - src/app/products/[slug]/page.tsx | 28 +++--- src/components/Carousel.tsx | 2 +- src/components/Faq.tsx | 49 +++++++--- src/components/FeaturedCategories.tsx | 22 +++-- src/components/Icons.tsx | 8 ++ src/components/Placeholder.tsx | 6 +- src/components/ProductBuyBox.tsx | 58 ++++++++++++ src/components/ProductCard.tsx | 96 +++++++++++--------- src/components/ProductCarousel.tsx | 28 +++--- src/components/ProductGallery.tsx | 111 ++++++++++++++++++++++- src/components/ProductTabs.tsx | 123 ++++++++++++++++++++++++++ src/data/content.ts | 16 ++-- src/lib/auth.ts | 60 +++++++++++++ src/lib/format.ts | 5 ++ src/lib/site.ts | 9 ++ src/lib/static-images.ts | 4 +- 18 files changed, 570 insertions(+), 110 deletions(-) create mode 100644 src/components/ProductBuyBox.tsx create mode 100644 src/components/ProductTabs.tsx create mode 100644 src/lib/auth.ts diff --git a/src/app/globals.css b/src/app/globals.css index 0d8cd7d..7aa84ae 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -88,6 +88,42 @@ body { border: 1px solid rgb(255 255 255 / 0.55); } +.faq-band { + position: relative; + isolation: isolate; + background-color: #efe7ff; + background-image: + radial-gradient(ellipse 42% 55% at 8% 30%, rgb(255 214 228 / 0.78), transparent 62%), + radial-gradient(ellipse 38% 50% at 94% 72%, rgb(165 243 252 / 0.55), transparent 64%), + radial-gradient(ellipse 50% 40% at 52% 0%, rgb(216 198 255 / 0.7), transparent 70%), + radial-gradient(circle, rgb(109 40 217 / 0.13) 1.15px, transparent 1.25px); + background-size: auto, auto, auto, 26px 26px; +} + +.faq-band::before, +.faq-band::after { + content: ""; + position: absolute; + inset-inline: 0; + z-index: 1; + height: 20px; + pointer-events: none; +} + +.faq-band::before { + top: 0; + background-image: radial-gradient(circle at 50% 0, var(--paper) 11px, transparent 12px); + background-size: 32px 20px; + background-repeat: repeat-x; +} + +.faq-band::after { + bottom: 0; + background-image: radial-gradient(circle at 50% 20px, var(--paper) 11px, transparent 12px); + background-size: 32px 20px; + background-repeat: repeat-x; +} + .font-display { font-family: var(--font-display); } @@ -115,6 +151,24 @@ body { } } +.carousel-card { + flex-shrink: 0; + scroll-snap-align: start; + width: 280px; +} + +@media (min-width: 640px) { + .carousel-card { + width: 300px; + } +} + +@media (min-width: 1280px) { + .carousel-card { + width: calc((100% - 5rem) / 6); + } +} + .product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); diff --git a/src/app/page.tsx b/src/app/page.tsx index bde0bf2..f0aedc1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -36,7 +36,6 @@ export default async function Home() { countdown leadBanner bannerSlot={slotByKey(slots, HOME_SLOT_KEYS.specialSale)} - href="/special-sale" /> {product.title} -
-
+
+
-
+

{product.brand}

{product.title}

{product.categoryName ?

{product.categoryName}

: null} @@ -107,24 +109,14 @@ export default async function ProductDetailsPage({ params }: Props) {
) : null} - {product.price != null && product.price > 0 ? ( - - خرید از حساب کاربری - - ) : null} - {product.summary ?

{product.summary}

: null} +
- {product.descriptionHtml ? ( -
-

توضیحات محصول

-
-
- ) : null} +
{children}
diff --git a/src/components/Faq.tsx b/src/components/Faq.tsx index 3625a75..498adbd 100644 --- a/src/components/Faq.tsx +++ b/src/components/Faq.tsx @@ -1,20 +1,43 @@ import { faqs } from "@/data/content"; +const accents = [ + "bg-purple-100 text-purple-700", + "bg-cyan-100 text-cyan-700", + "bg-rose-100 text-rose-600", + "bg-purple-50 text-cyan-700", +]; + export function Faq() { return ( -
-
-

FAQ

-

سوالات متداول

- -
-
- {faqs.map((item) => ( -
-

{item.q}

-

{item.a}

-
- ))} +
+
+
+

FAQ

+

سوالات متداول

+ +
+
+ {faqs.map((item, index) => ( +
+
+ + {String(index + 1).padStart(2, "0")} + +
+

{item.q}

+

{item.a}

+
+
+
+ ))} +
); diff --git a/src/components/FeaturedCategories.tsx b/src/components/FeaturedCategories.tsx index 6d9d2cd..41ed4be 100644 --- a/src/components/FeaturedCategories.tsx +++ b/src/components/FeaturedCategories.tsx @@ -1,7 +1,6 @@ import { categories } from "@/data/content"; import { toFaDigits } from "@/lib/format"; import { - imageAlt, slotHref, slotImages, type StaticImageSlot, @@ -19,11 +18,14 @@ export function FeaturedCategories({ slot }: Props) { images.length > 0 ? images.map((image, index) => { const fallback = categories[index]; + const titleFa = (image.titleFa || image.title || fallback?.title || "").trim(); + const titleEn = (image.titleEn || "").trim(); return { id: image.id, image, fallbackSrc: fallback?.image ?? categories[0].image, - title: imageAlt(image, fallback?.title ?? ""), + titleFa, + titleEn: titleFa && titleEn && titleEn !== titleFa ? titleEn : "", href: slotHref(image) || "#", count: fallback?.count, subtext: image.subtext, @@ -33,7 +35,8 @@ export function FeaturedCategories({ slot }: Props) { id: category.id, image: undefined, fallbackSrc: category.image, - title: category.title, + titleFa: category.title, + titleEn: category.titleEn && category.titleEn !== category.title ? category.titleEn : "", href: "#", count: category.count, subtext: undefined as string | null | undefined, @@ -52,14 +55,23 @@ export function FeaturedCategories({ slot }: Props) { -

{item.title}

+

{item.titleFa}

+ {item.titleEn ? ( +

+ {item.titleEn} +

+ ) : null} {item.subtext ? (

{item.subtext}

) : item.count != null ? ( diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 1bad6ef..ce3aa48 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -158,6 +158,14 @@ export function IconPlus({ className = "size-5" }: IconProps) { ); } +export function IconMinus({ className = "size-5" }: IconProps) { + return ( + + + + ); +} + export function IconClose({ className = "size-6" }: IconProps) { return ( diff --git a/src/components/Placeholder.tsx b/src/components/Placeholder.tsx index ac45804..6ce01f0 100644 --- a/src/components/Placeholder.tsx +++ b/src/components/Placeholder.tsx @@ -6,6 +6,7 @@ type Props = { className?: string; rounded?: string; fill?: boolean; + fit?: "cover" | "contain"; }; export function Placeholder({ @@ -16,6 +17,7 @@ export function Placeholder({ className = "", rounded = "rounded-2xl", fill = false, + fit = "cover", }: Props) { return ( {alt} ); } diff --git a/src/components/ProductBuyBox.tsx b/src/components/ProductBuyBox.tsx new file mode 100644 index 0000000..b0f6d12 --- /dev/null +++ b/src/components/ProductBuyBox.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { toFaDigits } from "@/lib/format"; +import { IconBag, IconMinus, IconPlus } from "./Icons"; + +type Props = { + href: string; + disabled?: boolean; +}; + +export function ProductBuyBox({ href, disabled = false }: Props) { + const [qty, setQty] = useState(1); + + return ( +
+
+ + + {toFaDigits(qty)} + + +
+ {disabled ? ( + + ) : ( + + + افزودن به سبد خرید + + )} +
+ ); +} diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index e1624f1..eba8b30 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { CUSTOMER_URL } from "@/lib/site"; import type { Product } from "@/lib/catalog"; -import { formatPrice } from "@/lib/format"; +import { formatDiscountPercent, formatPrice } from "@/lib/format"; import { IconPlus } from "./Icons"; import { Placeholder } from "./Placeholder"; @@ -12,68 +12,76 @@ type Props = { export function ProductCard({ product, fill = false }: Props) { const canAdd = product.price != null && product.price > 0; + const discount = formatDiscountPercent(product.price, product.oldPrice); + const labelBadge = product.badge && !product.badge.includes("٪") ? product.badge : null; return (
- - - -
-
-

{product.brand}

- {product.badge ? ( - - {product.badge} - - ) : null} + +
+
+ {discount ? ( + + {discount} + + ) : null} + +
+ {labelBadge ? ( + + {labelBadge} + + ) : null} -

+

{product.title}

- {product.colors.length ? ( -
- {product.colors.slice(0, 5).map((color) => ( - - ))} -
- ) : null} -
-
+
+ {product.colors.slice(0, 5).map((color) => ( + + ))} +
+
+
{product.oldPrice ? (

{formatPrice(product.oldPrice)}

) : null} -

{formatPrice(product.price)}

+

+ {formatPrice(product.price)} +

{canAdd ? ( diff --git a/src/components/ProductCarousel.tsx b/src/components/ProductCarousel.tsx index 2c7379f..4e59061 100644 --- a/src/components/ProductCarousel.tsx +++ b/src/components/ProductCarousel.tsx @@ -47,21 +47,23 @@ export function ProductCarousel({ {leadBanner ? (
- +
+ +
) : null} {products.map((product) => ( diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx index 80e3af1..3441a28 100644 --- a/src/components/ProductGallery.tsx +++ b/src/components/ProductGallery.tsx @@ -1,18 +1,70 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Placeholder } from "@/components/Placeholder"; +import { IconChevron, IconClose } from "./Icons"; export function ProductGallery({ images, title }: { images: string[]; title: string }) { const gallery = images.filter(Boolean); const [active, setActive] = useState(0); + const [open, setOpen] = useState(false); + const [origin, setOrigin] = useState("50% 50%"); const current = gallery[active] || gallery[0]; + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + if (gallery.length < 2) return; + if (event.key === "ArrowRight") setActive((index) => (index + 1) % gallery.length); + if (event.key === "ArrowLeft") setActive((index) => (index - 1 + gallery.length) % gallery.length); + }; + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = previous; + window.removeEventListener("keydown", onKey); + }; + }, [open, gallery.length]); + if (!current) return null; + const show = (index: number) => { + setActive((index + gallery.length) % gallery.length); + }; + return (
- + {gallery.length > 1 ? (
{gallery.slice(0, 8).map((image, index) => ( @@ -20,15 +72,66 @@ export function ProductGallery({ images, title }: { images: string[]; title: str key={`${image}-${index}`} type="button" onClick={() => setActive(index)} - className={`overflow-hidden rounded-2xl ${ + className={`size-[72px] shrink-0 overflow-hidden rounded-2xl ${ index === active ? "ring-2 ring-purple-600" : "ring-1 ring-line" }`} > - + ))}
) : null} + + {open ? ( +
setOpen(false)} + role="dialog" + aria-modal="true" + aria-label={title} + > + + {gallery.length > 1 ? ( + <> + + + + ) : null} + {title} event.stopPropagation()} + /> +
+ ) : null}
); } diff --git a/src/components/ProductTabs.tsx b/src/components/ProductTabs.tsx new file mode 100644 index 0000000..ea77014 --- /dev/null +++ b/src/components/ProductTabs.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuthUser } from "@/lib/auth"; +import { customerLoginUrl } from "@/lib/site"; + +type Tab = "description" | "comments"; + +type Props = { + descriptionHtml?: string; + summary?: string; +}; + +export function ProductTabs({ descriptionHtml, summary }: Props) { + const [tab, setTab] = useState("description"); + const [loginHref, setLoginHref] = useState(customerLoginUrl()); + const { user, ready, isLoggedIn } = useAuthUser(); + + useEffect(() => { + const url = new URL(window.location.href); + if (url.searchParams.get("tab") === "comments") setTab("comments"); + url.searchParams.set("tab", "comments"); + setLoginHref(customerLoginUrl(url.toString())); + }, []); + + return ( +
+
+ setTab("description")}> + توضیحات + + setTab("comments")}> + نظرات + +
+ + {tab === "description" ? ( +
+ {descriptionHtml ? ( +
+ ) : summary ? ( +

{summary}

+ ) : ( +

توضیحاتی برای این محصول ثبت نشده است.

+ )} +
+ ) : ( +
+ {!ready ? ( +

در حال بررسی ورود...

+ ) : isLoggedIn ? ( + + ) : ( +
+

+ برای ثبت دیدگاه باید وارد حساب کاربری شوید. +

+ + ورود به حساب کاربری + +
+ )} +
+ )} +
+ ); +} + +function CommentForm({ defaultName }: { defaultName?: string }) { + return ( +
{ + event.preventDefault(); + }} + > + +