Add fixed categories dock and specials-driven home carousels.

Dock links use the six API category slugs so storefront navigation stays connected to catalog filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-23 15:44:53 +03:30
co-authored by Cursor
parent 35939e6ee4
commit 6c060e222c
24 changed files with 1131 additions and 92 deletions
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="enamad" content="16489694" />
<title>شیرینی بلوط - صفحه اصلی</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+2
View File
@@ -3,6 +3,7 @@ import Header from './components/Header'
import Footer from './components/Footer'
import ToastHost from './components/ToastHost'
import CartDrawer from './components/CartDrawer'
import CategoriesDock from './components/CategoriesDock'
import HomePage from './pages/HomePage'
import QuickInfoPage from './pages/QuickInfoPage'
import ContactUsPage from './pages/ContactUsPage'
@@ -26,6 +27,7 @@ export default function App() {
</Routes>
</main>
<Footer />
<CategoriesDock />
<ToastHost />
<CartDrawer />
</div>
+151
View File
@@ -0,0 +1,151 @@
.categories-dock {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 80;
width: min(var(--max-container), calc(100vw - 48px));
padding: 14px 24px;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.7);
background:
linear-gradient(
165deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(255, 247, 241, 0.55) 45%,
rgba(255, 255, 255, 0.4) 100%
);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow:
0 10px 36px rgba(143, 65, 12, 0.14),
0 2px 8px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.85),
inset 0 -1px 0 rgba(143, 65, 12, 0.04);
pointer-events: auto;
}
.categories-dock__list {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.categories-dock__item {
flex: 1 1 0;
min-width: 0;
}
.categories-dock__link {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--color-primary);
text-decoration: none;
transition: transform 0.2s ease;
}
.categories-dock__link:hover {
transform: translateY(-3px);
}
.categories-dock__image-wrap {
flex-shrink: 0;
width: clamp(52px, 5.5vw, 68px);
height: clamp(52px, 5.5vw, 68px);
border-radius: 50%;
overflow: hidden;
background: transparent;
}
.categories-dock__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.categories-dock__names {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.categories-dock__name-fa {
font-size: clamp(13px, 1.4vw, 16px);
font-weight: 600;
line-height: 1.2;
color: var(--color-primary);
}
.categories-dock__name-en {
font-family: Cailyne, serif;
font-size: clamp(11px, 1.2vw, 13px);
font-weight: 300;
line-height: 1.2;
color: var(--color-secondary);
opacity: 0.75;
}
@media (max-width: 900px) {
.categories-dock {
width: calc(100vw - 24px);
padding: 12px 14px;
}
.categories-dock__list {
gap: 8px;
}
.categories-dock__link {
gap: 6px;
}
.categories-dock__name-en {
font-size: 11px;
}
}
@media (max-width: 640px) {
.categories-dock {
bottom: 10px;
width: calc(100vw - 12px);
padding: 8px 8px;
border-radius: 22px;
}
.categories-dock__list {
gap: 4px;
}
.categories-dock__link {
flex-direction: column;
gap: 4px;
}
.categories-dock__image-wrap {
width: 44px;
height: 44px;
}
.categories-dock__names {
align-items: center;
}
.categories-dock__name-fa {
font-size: 10px;
}
.categories-dock__name-en {
display: none;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Link } from 'react-router-dom'
import './CategoriesDock.css'
/**
* Fixed glossy dock — images and labels are hardcoded.
* Each item links to `/products/:slug` matching the API category slug.
*/
const DOCK_CATEGORIES = [
{
nameFa: 'کیک',
nameEn: 'Cake',
image: '/categories/Cake.png',
slug: 'cake',
},
{
nameFa: 'شیرینی',
nameEn: 'Pastry',
image: '/categories/pastry.png',
slug: 'pastry',
},
{
nameFa: 'سوهان',
nameEn: 'Sohan',
image: '/categories/sohan.png',
slug: 'sohan',
},
{
nameFa: 'دسر',
nameEn: 'Dessert',
image: '/categories/desert.png',
slug: 'desert',
},
{
nameFa: 'شکلات',
nameEn: 'Chocolate',
image: '/categories/Choclate.png',
slug: 'chocolate',
},
{
nameFa: 'باقلوا',
nameEn: 'Baklava',
image: '/categories/baghlava.png',
slug: 'baklava',
},
] as const
export default function CategoriesDock() {
return (
<nav className="categories-dock" aria-label="دسته‌بندی محصولات">
<ul className="categories-dock__list">
{DOCK_CATEGORIES.map((category) => (
<li key={category.slug} className="categories-dock__item">
<Link
to={`/products/${category.slug}`}
className="categories-dock__link"
>
<span className="categories-dock__image-wrap">
<img src={category.image} alt="" className="categories-dock__image" />
</span>
<span className="categories-dock__names">
<span className="categories-dock__name-fa">{category.nameFa}</span>
<span className="categories-dock__name-en">{category.nameEn}</span>
</span>
</Link>
</li>
))}
</ul>
</nav>
)
}
+103
View File
@@ -0,0 +1,103 @@
.image-lightbox {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 24px;
}
.image-lightbox__backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(20, 12, 8, 0.78);
cursor: pointer;
}
.image-lightbox__close {
position: absolute;
top: 18px;
inset-inline-end: 18px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.92);
color: var(--color-primary);
cursor: pointer;
}
.image-lightbox__nav {
position: absolute;
top: 50%;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.92);
color: var(--color-primary);
transform: translateY(-50%);
cursor: pointer;
}
.image-lightbox__nav--prev {
inset-inline-start: 18px;
}
.image-lightbox__nav--next {
inset-inline-end: 18px;
}
.image-lightbox__stage {
position: relative;
z-index: 1;
max-width: min(920px, 100%);
max-height: calc(100vh - 48px);
}
.image-lightbox__image {
display: block;
max-width: 100%;
max-height: calc(100vh - 48px);
width: auto;
height: auto;
object-fit: contain;
border-radius: 16px;
background: #fff;
}
.image-lightbox__counter {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 13px;
}
@media (max-width: 640px) {
.image-lightbox {
padding: 12px;
}
.image-lightbox__nav--prev {
inset-inline-start: 8px;
}
.image-lightbox__nav--next {
inset-inline-end: 8px;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import './ImageLightbox.css'
type ImageLightboxProps = {
images: string[]
index: number
alt?: string
onClose: () => void
onIndexChange: (index: number) => void
}
export default function ImageLightbox({
images,
index,
alt = '',
onClose,
onIndexChange,
}: ImageLightboxProps) {
const safeIndex = ((index % images.length) + images.length) % images.length
const current = images[safeIndex]
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
if (event.key === 'ArrowLeft') onIndexChange(safeIndex + 1)
if (event.key === 'ArrowRight') onIndexChange(safeIndex - 1)
}
document.addEventListener('keydown', onKey)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prevOverflow
}
}, [onClose, onIndexChange, safeIndex])
if (!current) return null
return createPortal(
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="گالری تصاویر">
<button
type="button"
className="image-lightbox__backdrop"
aria-label="بستن"
onClick={onClose}
/>
<button
type="button"
className="image-lightbox__close"
aria-label="بستن"
onClick={onClose}
>
<X size={22} />
</button>
{images.length > 1 && (
<>
<button
type="button"
className="image-lightbox__nav image-lightbox__nav--prev"
aria-label="قبلی"
onClick={() => onIndexChange(safeIndex + 1)}
>
<ChevronRight size={22} />
</button>
<button
type="button"
className="image-lightbox__nav image-lightbox__nav--next"
aria-label="بعدی"
onClick={() => onIndexChange(safeIndex - 1)}
>
<ChevronLeft size={22} />
</button>
</>
)}
<div className="image-lightbox__stage">
<img src={current} alt={alt} className="image-lightbox__image" />
{images.length > 1 && (
<p className="image-lightbox__counter" dir="ltr">
{safeIndex + 1} / {images.length}
</p>
)}
</div>
</div>,
document.body,
)
}
+38 -23
View File
@@ -1,9 +1,10 @@
.store-card {
display: block;
background-color: rgba(143, 65, 12, 0.05);
border-radius: 100px 100px 25px 25px;
border-radius: 100px 100px 22px 22px;
padding: 12px;
width: 100%;
height: 100%;
color: inherit;
transition: transform 0.2s ease, background-color 0.2s ease;
}
@@ -16,26 +17,28 @@
.store-card__image {
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: 88px 88px 0 0;
}
.store-card__image img {
border-radius: 90px 90px 0 0;
border-radius: 88px 88px 0 0;
height: 100%;
width: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.store-card__description {
margin-top: 7px;
padding-inline: 4px 8px;
margin-top: 10px;
padding-inline: 8px 10px;
padding-bottom: 8px;
}
.store-card__title {
color: var(--color-gray-400);
font-size: clamp(13px, 3vw, 18px);
min-height: 48px;
font-size: clamp(12px, 2.2vw, 14px);
min-height: 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -43,57 +46,69 @@
overflow: hidden;
}
.store-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.store-card__subtitle {
color: var(--color-primary);
font-size: clamp(15px, 3vw, 20px);
margin-top: 4px;
font-size: clamp(12px, 2.2vw, 15px);
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px;
min-width: 0;
}
.store-card__subtitle-detail {
color: var(--color-secondary);
font-size: clamp(8px, 2vw, 10px);
font-size: clamp(8px, 1.6vw, 9px);
}
.store-card__subtitle-weight {
color: rgba(94, 94, 94, 0.5);
font-size: clamp(10px, 2vw, 13px);
}
.store-card__actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
font-size: clamp(9px, 1.8vw, 11px);
}
.store-card__arrow {
color: var(--color-primary);
display: inline-flex;
flex-shrink: 0;
}
@media (max-width: 678px) {
.store-card {
border-radius: 60px 60px 15px 15px;
padding: 7px;
border-radius: 60px 60px 16px 16px;
padding: 8px;
}
.store-card__image {
border-radius: 52px 52px 0 0;
}
.store-card__image img {
border-radius: 50px 50px 0 0;
border-radius: 52px 52px 0 0;
}
.store-card__description {
margin-top: 8px;
padding-inline: 6px 8px;
padding-bottom: 6px;
}
.store-card__title {
min-height: 0;
font-size: 13px;
font-size: 12px;
}
.store-card__subtitle {
font-size: 14px;
font-size: 12px;
}
.store-card__actions {
.store-card__arrow {
display: none;
}
}
+6 -6
View File
@@ -19,12 +19,12 @@ export default function ProductCard({ product }: ProductCardProps) {
</div>
<div className="store-card__description">
<div className="store-card__title">{product.nameFa}</div>
<div className="store-card__subtitle">
<span>{price.amount}</span>
<span className="store-card__subtitle-detail"> تومان</span>
<span className="store-card__subtitle-weight"> /{price.unitLabel}</span>
</div>
<div className="store-card__actions">
<div className="store-card__footer">
<div className="store-card__subtitle">
<span>{price.amount}</span>
<span className="store-card__subtitle-detail"> تومان</span>
<span className="store-card__subtitle-weight"> /{price.unitLabel}</span>
</div>
<span className="store-card__arrow" aria-hidden="true">
<ChevronLeft size={18} />
</span>
+198
View File
@@ -0,0 +1,198 @@
.product-carousel {
margin-top: 96px;
}
.product-carousel--framed {
margin-top: 96px;
padding-block: 52px;
background:
linear-gradient(
180deg,
rgba(143, 65, 12, 0.055) 0%,
rgba(143, 65, 12, 0.028) 48%,
rgba(143, 65, 12, 0.055) 100%
);
border-top: 1px solid rgba(143, 65, 12, 0.16);
border-bottom: 1px solid rgba(143, 65, 12, 0.16);
}
/* Title (1 slot) + 4 visible product cards = 5 equal columns */
.product-carousel__row {
--carousel-gap: 8px;
--carousel-slots: 5;
display: flex;
align-items: stretch;
gap: var(--carousel-gap);
}
.product-carousel__aside {
flex: 0 0
calc(
(100% - (var(--carousel-slots) - 1) * var(--carousel-gap)) / var(--carousel-slots)
);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 14px;
min-width: 0;
}
.product-carousel__title {
width: 100%;
}
.product-carousel__aside .size-lg .header-title__placeholder {
font-size: clamp(40px, 6.5vw, 68px);
line-height: 0.9;
word-break: break-word;
}
.product-carousel__aside .size-lg .header-title__primary {
font-size: clamp(20px, 2.8vw, 32px);
white-space: normal;
}
.product-carousel__aside .size-lg .header-title__secondary {
font-size: clamp(13px, 1.8vw, 17px);
white-space: normal;
}
.product-carousel__all {
display: inline-block;
position: relative;
color: var(--color-primary);
font-size: 14px;
font-weight: 500;
line-height: 1.4;
padding: 0;
background: none;
border: none;
text-decoration: none;
}
.product-carousel__all::after {
content: '';
position: absolute;
inset-inline-start: 0;
bottom: -2px;
width: 0;
height: 1px;
background: currentColor;
transition: width 0.25s ease;
}
.product-carousel__all:hover::after {
width: 100%;
}
.product-carousel__stage {
position: relative;
flex: 1;
min-width: 0;
}
.product-carousel__nav {
position: absolute;
top: 42%;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid rgba(143, 65, 12, 0.18);
border-radius: 50%;
background: rgba(255, 255, 255, 0.92);
color: var(--color-primary);
box-shadow: 0 2px 10px rgba(75, 75, 75, 0.08);
transform: translateY(-50%);
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.product-carousel__nav:hover {
background: #fff;
border-color: rgba(143, 65, 12, 0.4);
box-shadow: 0 4px 14px rgba(75, 75, 75, 0.12);
}
/* Over first / last visible product cards */
.product-carousel__nav--start {
inset-inline-start: 10px;
}
.product-carousel__nav--end {
inset-inline-end: 10px;
}
.product-carousel__track {
display: flex;
gap: var(--carousel-gap);
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 8px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.product-carousel__track::-webkit-scrollbar {
display: none;
}
.product-carousel__item {
/* 4 cards fill the remaining 4/5 of the row */
flex: 0 0
calc((100% - 3 * var(--carousel-gap)) / 4);
scroll-snap-align: start;
min-width: 0;
}
.product-carousel__state {
min-height: 280px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-secondary);
font-size: 14px;
}
@media (max-width: 1024px) {
.product-carousel__row {
--carousel-slots: 4;
}
.product-carousel__item {
flex-basis: calc((100% - 2 * var(--carousel-gap)) / 3);
}
}
@media (max-width: 768px) {
.product-carousel {
margin-top: 64px;
}
.product-carousel--framed {
margin-top: 64px;
padding-block: 36px;
}
.product-carousel__row {
flex-direction: column;
--carousel-gap: 8px;
}
.product-carousel__aside {
flex-basis: auto;
width: 100%;
max-width: 280px;
}
.product-carousel__item {
flex-basis: calc((100% - var(--carousel-gap)) / 2);
}
.product-carousel__nav {
width: 36px;
height: 36px;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { useRef } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import HeaderTitle from './HeaderTitle'
import ProductCard from './ProductCard'
import type { Product } from '../lib/types'
import './ProductCarousel.css'
type ProductCarouselProps = {
id?: string
placeholder: string
secondary: string
primary: string
products: Product[]
loading?: boolean
viewAllTo?: string
className?: string
}
export default function ProductCarousel({
id,
placeholder,
secondary,
primary,
products,
loading = false,
viewAllTo = '/products',
className = '',
}: ProductCarouselProps) {
const scrollerRef = useRef<HTMLDivElement>(null)
function scrollByCard(direction: 1 | -1) {
const el = scrollerRef.current
if (!el) return
const card = el.querySelector<HTMLElement>('[data-carousel-card]')
const styles = getComputedStyle(el)
const gap = Number.parseFloat(styles.columnGap || styles.gap || '8') || 8
const amount = (card?.offsetWidth ?? 220) + gap
const rtl = styles.direction === 'rtl'
el.scrollBy({ left: amount * direction * (rtl ? -1 : 1), behavior: 'smooth' })
}
if (!loading && products.length === 0) return null
return (
<section id={id} className={`product-carousel ${className}`.trim()}>
<div className="max-container">
{loading ? (
<div className="product-carousel__state" role="status">
در حال بارگذاری محصولات...
</div>
) : (
<div className="product-carousel__row">
<aside className="product-carousel__aside">
<HeaderTitle
placeholder={placeholder}
secondary={secondary}
primary={primary}
size="lg"
align="start"
className="product-carousel__title"
/>
{viewAllTo && (
<Link to={viewAllTo} className="product-carousel__all">
مشاهده همه
</Link>
)}
</aside>
<div className="product-carousel__stage">
<button
type="button"
className="product-carousel__nav product-carousel__nav--start"
onClick={() => scrollByCard(-1)}
aria-label="قبلی"
>
<ChevronRight size={18} />
</button>
<div ref={scrollerRef} className="product-carousel__track">
{products.map((product) => (
<div key={product.id} data-carousel-card className="product-carousel__item">
<ProductCard product={product} />
</div>
))}
</div>
<button
type="button"
className="product-carousel__nav product-carousel__nav--end"
onClick={() => scrollByCard(1)}
aria-label="بعدی"
>
<ChevronLeft size={18} />
</button>
</div>
</div>
)}
</div>
</section>
)
}
+1
View File
@@ -101,6 +101,7 @@ button {
min-height: 100vh;
background: var(--color-bg);
overflow-x: hidden;
padding-bottom: 120px;
}
.max-container {
+2
View File
@@ -9,6 +9,7 @@ export type ListProductsParams = {
q?: string
categoryId?: string
categorySlug?: string
tag?: string
minPrice?: number
maxPrice?: number
page?: number
@@ -112,6 +113,7 @@ function buildQuery(params: ListProductsParams) {
if (params.q?.trim()) search.set('q', params.q.trim())
if (params.categoryId) search.set('categoryId', params.categoryId)
if (params.categorySlug) search.set('categorySlug', params.categorySlug)
if (params.tag?.trim()) search.set('tag', params.tag.trim())
if (params.minPrice !== undefined) search.set('minPrice', String(params.minPrice))
if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
if (params.page) search.set('page', String(params.page))
+64
View File
@@ -0,0 +1,64 @@
/**
* Home product carousels driven by admin special tags (ویژه).
* Sort by `order`. English placeholders are fixed for the website.
* Carousels with zero products are not rendered.
*/
export type HomeCarouselDef = {
id: string
/** Matches Product.tags values set in the dashboard specials UI */
tag: string
order: number
placeholder: string
secondary: string
primary: string
/** Full-bleed band with top/bottom borders */
framed?: boolean
}
export const HOME_PRODUCT_CAROUSELS: HomeCarouselDef[] = [
{
id: 'recommended',
tag: 'پیشنهادی',
order: 1,
placeholder: 'Recommended',
secondary: 'محصولات',
primary: 'پیشنهادی مــــا',
},
{
id: 'bestsellers',
tag: 'پرفروش',
order: 2,
placeholder: 'Best Sellers',
secondary: 'محصولات',
primary: 'پرفــــــــروش',
},
{
id: 'ready-to-ship',
tag: 'آماده',
order: 3,
placeholder: 'Ready to Ship',
secondary: 'محصولات',
primary: 'آماده ارســــال',
},
{
id: 'new-products',
tag: 'جدید',
order: 4,
placeholder: 'New Arrivals',
secondary: 'محصولات',
primary: 'جدیــــــــد',
framed: true,
},
{
id: 'gift',
tag: 'هدیه',
order: 5,
placeholder: 'Gift Ideas',
secondary: 'محصولات',
primary: 'مناسب هدیــــه',
},
]
export function sortedHomeCarousels(defs = HOME_PRODUCT_CAROUSELS) {
return [...defs].sort((a, b) => a.order - b.order)
}
+2
View File
@@ -39,6 +39,8 @@ export type Product = {
intro?: string | null
description?: string | null
tags?: string[]
preparationHours?: number | null
createdAt?: string
mainImageUrl: string | null
mainImageKey?: string | null
gallery?: ProductGalleryImage[]
+26 -18
View File
@@ -165,14 +165,9 @@
position: relative;
z-index: 1;
overflow: visible;
}
.sohan-section__top {
display: flex;
align-items: flex-end;
gap: 12px;
margin-bottom: 20px;
overflow: visible;
align-items: center;
gap: 28px;
}
.sohan-section__media {
@@ -207,10 +202,18 @@
user-select: none;
}
.sohan-section__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
z-index: 2;
}
.sohan-section__title {
width: 100%;
min-width: 0;
z-index: 2;
}
.sohan-section__text {
@@ -218,8 +221,7 @@
line-height: 1.8;
text-align: justify;
color: var(--color-secondary);
position: relative;
z-index: 2;
margin: 0;
}
/* Offers wrapper */
@@ -377,10 +379,11 @@
margin-top: 120px;
}
.sohan-section__top {
.sohan-section__inner {
flex-direction: column;
align-items: center;
gap: 4px;
gap: 12px;
text-align: center;
}
.sohan-section__media {
@@ -393,6 +396,11 @@
width: 110%;
}
.sohan-section__content {
align-items: center;
width: 100%;
}
.sohan-section__title .header-title__placeholder {
font-size: clamp(44px, 10vw, 72px);
}
@@ -402,6 +410,10 @@
transform: none;
}
.sohan-section__text {
text-align: center;
}
.home-footer-block {
margin-top: 200px;
}
@@ -466,11 +478,7 @@
.sohan-section__inner {
padding-inline: 0;
}
.sohan-section__top {
gap: 0;
margin-bottom: 12px;
gap: 8px;
}
.sohan-section__media {
+88 -10
View File
@@ -2,6 +2,13 @@ import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft } from 'lucide-react'
import HeaderTitle from '../components/HeaderTitle'
import ProductCarousel from '../components/ProductCarousel'
import { listProducts } from '../lib/api'
import {
sortedHomeCarousels,
type HomeCarouselDef,
} from '../lib/homeCarousels'
import type { Product } from '../lib/types'
import macaronRight from '../assets/images/brown-macaron-right.png'
import macaronLeft from '../assets/images/brown-macaron-left.png'
import chocolateCake from '../assets/images/chocolate-cake.png'
@@ -16,6 +23,13 @@ import flatLay from '../assets/images/FlatLay.png'
import peste from '../assets/images/peste.png'
import './HomePage.css'
const CAROUSEL_LIMIT = 12
/** Fixed placement slots between homepage frames; filled by order. */
const SLOT_COUNT = 4
type CarouselSection = HomeCarouselDef & { products: Product[] }
const DECOR_CARDS = [
{
title: 'انواع',
@@ -58,11 +72,66 @@ const OFFER_CATEGORIES = [
export default function HomePage() {
const [openOffer, setOpenOffer] = useState(0)
const [carousels, setCarousels] = useState<CarouselSection[]>([])
const [carouselsLoading, setCarouselsLoading] = useState(true)
useEffect(() => {
document.title = 'شیرینی بلوط - صفحه اصلی'
}, [])
useEffect(() => {
let cancelled = false
setCarouselsLoading(true)
const defs = sortedHomeCarousels()
Promise.all(
defs.map(async (def) => {
try {
const result = await listProducts({
tag: def.tag,
pageSize: CAROUSEL_LIMIT,
})
return { ...def, products: result.items }
} catch {
return { ...def, products: [] as Product[] }
}
}),
)
.then((results) => {
if (cancelled) return
setCarousels(results.filter((item) => item.products.length > 0))
})
.finally(() => {
if (!cancelled) setCarouselsLoading(false)
})
return () => {
cancelled = true
}
}, [])
const slotted = carousels.slice(0, SLOT_COUNT)
const overflow = carousels.slice(SLOT_COUNT)
function renderCarousel(section: CarouselSection | undefined) {
if (!section) return null
return (
<ProductCarousel
key={section.id}
id={section.id}
placeholder={section.placeholder}
secondary={section.secondary}
primary={section.primary}
products={section.products}
loading={carouselsLoading}
viewAllTo="/products"
className={section.framed ? 'product-carousel--framed' : undefined}
/>
)
}
return (
<div className="home">
{/* Hero title */}
@@ -89,6 +158,8 @@ export default function HomePage() {
</div>
</div>
{renderCarousel(slotted[0])}
{/* For The Best */}
<HeaderTitle
placeholder="For The Best"
@@ -130,6 +201,8 @@ export default function HomePage() {
</div>
</div>
{renderCarousel(slotted[1])}
{/* About */}
<div className="max-container about-section">
<div className="about-section__image">
@@ -152,15 +225,17 @@ export default function HomePage() {
</div>
</div>
{renderCarousel(slotted[2])}
{/* Sohan */}
<section className="sohan-section">
<img src={peste} alt="" className="sohan-section__peste" />
<div className="sohan-section__inner">
<div className="sohan-section__top">
<div className="sohan-section__media">
<img src={sohan} alt="سوهان" className="sohan-section__image" />
<img src={sohanPattern} alt="" className="sohan-section__pattern" />
</div>
<div className="sohan-section__media">
<img src={sohan} alt="سوهان" className="sohan-section__image" />
<img src={sohanPattern} alt="" className="sohan-section__pattern" />
</div>
<div className="sohan-section__content">
<HeaderTitle
placeholder="Sohan"
secondary="سوهان"
@@ -169,15 +244,18 @@ export default function HomePage() {
align="start"
className="sohan-section__title"
/>
<p className="sohan-section__text">
سوهان یکی از اصیل‌ترین سوغات قم است که در شیرینی‌سرای بلوط با استفاده از مواد اولیه
باکیفیت و تازه تهیه می‌شود. عطر دل‌انگیز کره و زعفران در کنار بافتی لطیف و طعمی
ماندگار، سوهان بلوط را به انتخابی مناسب برای پذیرایی و هدیه تبدیل کرده است.
</p>
</div>
<p className="sohan-section__text">
سوهان یکی از اصیل‌ترین سوغات قم است که در شیرینی‌سرای بلوط با استفاده از مواد اولیه
باکیفیت و تازه تهیه می‌شود. عطر دل‌انگیز کره و زعفران در کنار بافتی لطیف و طعمی
ماندگار، سوهان بلوط را به انتخابی مناسب برای پذیرایی و هدیه تبدیل کرده است.
</p>
</div>
</section>
{renderCarousel(slotted[3])}
{overflow.map((section) => renderCarousel(section))}
{/* Special offers + hours */}
<div className="home-footer-block">
<section className="offers-wrapper">
+77 -2
View File
@@ -69,6 +69,10 @@
align-items: start;
}
.product-images--single {
grid-template-columns: 1fr;
}
.product-images__main img,
.product-images__thumbs img {
aspect-ratio: 1 / 1;
@@ -78,6 +82,34 @@
border-radius: 16px;
}
.product-images__main-btn {
position: relative;
display: block;
width: 100%;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
border-radius: 16px;
overflow: hidden;
}
.product-images__gallery-hint {
position: absolute;
inset-inline-start: 12px;
bottom: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--color-primary);
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.product-images__thumbs {
display: flex;
flex-direction: column;
@@ -92,10 +124,12 @@
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s ease, opacity 0.2s ease;
}
.product-images__thumbs button.is-active {
border-color: var(--color-primary);
.product-images__thumbs button:hover {
border-color: rgba(143, 65, 12, 0.35);
opacity: 0.9;
}
.product--name {
@@ -152,6 +186,47 @@
margin: 0;
}
.product-detail__options {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.product-detail__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(143, 65, 12, 0.12);
background: rgba(255, 255, 255, 0.45);
}
.product-detail__option-name {
color: var(--color-secondary);
font-size: clamp(12px, 3vw, 15px);
line-height: 1.5;
}
.product-detail__option-price {
flex-shrink: 0;
color: var(--color-primary);
font-size: clamp(11px, 2.8vw, 14px);
font-weight: 600;
white-space: nowrap;
}
.product-detail__options-hint {
margin: 10px 0 0;
color: var(--color-gray-400);
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1.5;
}
.product-detail__cart-wrap {
display: flex;
align-items: center;
+109 -33
View File
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, Minus, Plus, Star } from 'lucide-react'
import { ChevronLeft, ChevronRight, Images, Minus, Plus, Star } from 'lucide-react'
import PageHeader from '../components/PageHeader'
import ProductCard from '../components/ProductCard'
import ProductOptionsModal from '../components/ProductOptionsModal'
import ImageLightbox from '../components/ImageLightbox'
import { getProduct, listProducts } from '../lib/api'
import { addToCart } from '../lib/cart'
import type { Product, ProductOptionValue } from '../lib/types'
import { formatProductPrice, productImageSrc } from '../lib/types'
import handbagIcon from '../assets/images/Handbag.svg'
import { formatProductPrice, formatPriceFa, productImageSrc } from '../lib/types'
import cakeIcon from '../assets/images/Cake.svg'
import defaultPhoto from '../assets/images/DefaultPhoto.png'
import './ProductDetailPage.css'
@@ -21,16 +21,34 @@ function stripHtml(value: string) {
.trim()
}
function optionPreviewLabel(option: ProductOptionValue) {
const flavor = option.flavor?.nameFa?.trim()
if (flavor && option.amount) return `${flavor} — ${option.amount}`
return flavor || option.amount || 'گزینه'
}
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>()
const result: string[] = []
for (const url of urls) {
if (!url || seen.has(url)) continue
seen.add(url)
result.push(url)
}
return result
}
export default function ProductDetailPage() {
const { slugOrId = '', id = '' } = useParams()
const productId = id || slugOrId
const [product, setProduct] = useState<Product | null>(null)
const [related, setRelated] = useState<Product[]>([])
const [activeImage, setActiveImage] = useState<string>('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [quantity, setQuantity] = useState(1)
const [optionsOpen, setOptionsOpen] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
useEffect(() => {
document.title = 'شیرینی بلوط - صفحه جزئیات محصول'
@@ -43,16 +61,12 @@ export default function ProductDetailPage() {
setError(null)
setProduct(null)
setRelated([])
setLightboxOpen(false)
getProduct(productId)
.then((item) => {
if (cancelled) return
setProduct(item)
const galleryUrls = [
item.mainImageUrl,
...(item.gallery ?? []).map((g) => g.url),
].filter(Boolean) as string[]
setActiveImage(galleryUrls[0] || defaultPhoto)
setLoading(false)
listProducts({
@@ -79,18 +93,21 @@ export default function ProductDetailPage() {
}
}, [productId])
const thumbs = useMemo(() => {
const mainImage = product ? productImageSrc(product, defaultPhoto) : defaultPhoto
const galleryImages = useMemo(() => {
if (!product) return []
const urls = [
product.mainImageUrl,
...(product.gallery ?? []).map((g) => g.url),
].filter(Boolean) as string[]
if (urls.length === 0) return [defaultPhoto]
const unique = [...new Set(urls)]
if (unique.length === 1) return [unique[0], unique[0]]
return unique.slice(0, 6)
const main = product.mainImageUrl || ''
return uniqueUrls((product.gallery ?? []).map((g) => g.url)).filter((url) => url !== main)
}, [product])
const lightboxImages = useMemo(
() => uniqueUrls([mainImage, ...galleryImages]),
[mainImage, galleryImages],
)
const hasGallery = galleryImages.length > 0
const price = product ? formatProductPrice(product) : null
const abstractText = product?.intro ? stripHtml(product.intro) : ''
const descriptionHtml = product?.description?.trim() || ''
@@ -120,6 +137,12 @@ export default function ProductDetailPage() {
commitAdd()
}
function openLightbox(index = 0) {
if (lightboxImages.length === 0) return
setLightboxIndex(index)
setLightboxOpen(true)
}
useEffect(() => {
setQuantity(product?.sellUnit === 'kilo' ? 0.5 : 1)
setOptionsOpen(false)
@@ -135,7 +158,7 @@ export default function ProductDetailPage() {
</div>
<PageHeader
icon={handbagIcon}
icon={cakeIcon}
placeholder="Product Details"
secondary="جزئیات"
primary="محصــــول ما"
@@ -166,22 +189,42 @@ export default function ProductDetailPage() {
{!loading && product && price && (
<>
<div className="product-detail__grid">
<div className="product-images">
<div
className={`product-images${hasGallery ? '' : ' product-images--single'}`}
>
<div className="product-images__main">
<img src={activeImage || productImageSrc(product, defaultPhoto)} alt={product.nameFa} />
</div>
<div className="product-images__thumbs">
{thumbs.map((url, index) => (
<button
key={`${url}-${index}`}
type="button"
className={activeImage === url ? 'is-active' : undefined}
onClick={() => setActiveImage(url)}
>
<img src={url} alt="" />
</button>
))}
<button
type="button"
className="product-images__main-btn"
onClick={() => openLightbox(0)}
aria-label={
hasGallery ? 'باز کردن گالری تصاویر' : 'مشاهده تصویر بزرگ‌تر'
}
>
<img src={mainImage} alt={product.nameFa} />
{hasGallery && (
<span className="product-images__gallery-hint">
<Images size={16} />
گالری ({galleryImages.length})
</span>
)}
</button>
</div>
{hasGallery && (
<div className="product-images__thumbs">
{galleryImages.map((url, index) => (
<button
key={url}
type="button"
onClick={() => openLightbox(index + 1)}
aria-label={`تصویر گالری ${index + 1}`}
>
<img src={url} alt="" />
</button>
))}
</div>
)}
</div>
<div className="product-detail__info">
@@ -215,6 +258,29 @@ export default function ProductDetailPage() {
</div>
</div>
{(product.options?.length ?? 0) > 0 && (
<div className="product-detail__block">
<div className="caption__title">گزینه‌ها :</div>
<ul className="product-detail__options" aria-label="گزینه‌های محصول">
{product.options!.map((option) => (
<li key={option.id} className="product-detail__option">
<span className="product-detail__option-name">
{optionPreviewLabel(option)}
</span>
{option.price > 0 && (
<span className="product-detail__option-price">
+{formatPriceFa(option.price)} تومان
</span>
)}
</li>
))}
</ul>
<p className="product-detail__options-hint">
انتخاب گزینه هنگام افزودن به سبد خرید انجام می‌شود.
</p>
</div>
)}
<div className="product-detail__cart-wrap">
<div className="qty-stepper" dir="ltr">
<button
@@ -269,6 +335,16 @@ export default function ProductDetailPage() {
/>
)}
{lightboxOpen && lightboxImages.length > 0 && (
<ImageLightbox
images={lightboxImages}
index={lightboxIndex}
alt={product.nameFa}
onClose={() => setLightboxOpen(false)}
onIndexChange={setLightboxIndex}
/>
)}
{descriptionHtml && (
<section className="product-detail__description">
<div className="caption__title">توضیحات محصول :</div>