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" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="enamad" content="16489694" />
<title>شیرینی بلوط - صفحه اصلی</title> <title>شیرینی بلوط - صفحه اصلی</title>
</head> </head>
<body> <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 Footer from './components/Footer'
import ToastHost from './components/ToastHost' import ToastHost from './components/ToastHost'
import CartDrawer from './components/CartDrawer' import CartDrawer from './components/CartDrawer'
import CategoriesDock from './components/CategoriesDock'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import QuickInfoPage from './pages/QuickInfoPage' import QuickInfoPage from './pages/QuickInfoPage'
import ContactUsPage from './pages/ContactUsPage' import ContactUsPage from './pages/ContactUsPage'
@@ -26,6 +27,7 @@ export default function App() {
</Routes> </Routes>
</main> </main>
<Footer /> <Footer />
<CategoriesDock />
<ToastHost /> <ToastHost />
<CartDrawer /> <CartDrawer />
</div> </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 { .store-card {
display: block; display: block;
background-color: rgba(143, 65, 12, 0.05); background-color: rgba(143, 65, 12, 0.05);
border-radius: 100px 100px 25px 25px; border-radius: 100px 100px 22px 22px;
padding: 12px; padding: 12px;
width: 100%; width: 100%;
height: 100%;
color: inherit; color: inherit;
transition: transform 0.2s ease, background-color 0.2s ease; transition: transform 0.2s ease, background-color 0.2s ease;
} }
@@ -16,26 +17,28 @@
.store-card__image { .store-card__image {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
overflow: hidden; overflow: hidden;
border-radius: 88px 88px 0 0;
} }
.store-card__image img { .store-card__image img {
border-radius: 90px 90px 0 0; border-radius: 88px 88px 0 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
object-position: center;
display: block; display: block;
} }
.store-card__description { .store-card__description {
margin-top: 7px; margin-top: 10px;
padding-inline: 4px 8px; padding-inline: 8px 10px;
padding-bottom: 8px; padding-bottom: 8px;
} }
.store-card__title { .store-card__title {
color: var(--color-gray-400); color: var(--color-gray-400);
font-size: clamp(13px, 3vw, 18px); font-size: clamp(12px, 2.2vw, 14px);
min-height: 48px; min-height: 0;
line-height: 1.4; line-height: 1.4;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -43,57 +46,69 @@
overflow: hidden; overflow: hidden;
} }
.store-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.store-card__subtitle { .store-card__subtitle {
color: var(--color-primary); color: var(--color-primary);
font-size: clamp(15px, 3vw, 20px); font-size: clamp(12px, 2.2vw, 15px);
margin-top: 4px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: 2px; gap: 2px;
min-width: 0;
} }
.store-card__subtitle-detail { .store-card__subtitle-detail {
color: var(--color-secondary); color: var(--color-secondary);
font-size: clamp(8px, 2vw, 10px); font-size: clamp(8px, 1.6vw, 9px);
} }
.store-card__subtitle-weight { .store-card__subtitle-weight {
color: rgba(94, 94, 94, 0.5); color: rgba(94, 94, 94, 0.5);
font-size: clamp(10px, 2vw, 13px); font-size: clamp(9px, 1.8vw, 11px);
}
.store-card__actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
} }
.store-card__arrow { .store-card__arrow {
color: var(--color-primary); color: var(--color-primary);
display: inline-flex; display: inline-flex;
flex-shrink: 0;
} }
@media (max-width: 678px) { @media (max-width: 678px) {
.store-card { .store-card {
border-radius: 60px 60px 15px 15px; border-radius: 60px 60px 16px 16px;
padding: 7px; padding: 8px;
}
.store-card__image {
border-radius: 52px 52px 0 0;
} }
.store-card__image img { .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 { .store-card__title {
min-height: 0; font-size: 12px;
font-size: 13px;
} }
.store-card__subtitle { .store-card__subtitle {
font-size: 14px; font-size: 12px;
} }
.store-card__actions { .store-card__arrow {
display: none; display: none;
} }
} }
+1 -1
View File
@@ -19,12 +19,12 @@ export default function ProductCard({ product }: ProductCardProps) {
</div> </div>
<div className="store-card__description"> <div className="store-card__description">
<div className="store-card__title">{product.nameFa}</div> <div className="store-card__title">{product.nameFa}</div>
<div className="store-card__footer">
<div className="store-card__subtitle"> <div className="store-card__subtitle">
<span>{price.amount}</span> <span>{price.amount}</span>
<span className="store-card__subtitle-detail"> تومان</span> <span className="store-card__subtitle-detail"> تومان</span>
<span className="store-card__subtitle-weight"> /{price.unitLabel}</span> <span className="store-card__subtitle-weight"> /{price.unitLabel}</span>
</div> </div>
<div className="store-card__actions">
<span className="store-card__arrow" aria-hidden="true"> <span className="store-card__arrow" aria-hidden="true">
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</span> </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; min-height: 100vh;
background: var(--color-bg); background: var(--color-bg);
overflow-x: hidden; overflow-x: hidden;
padding-bottom: 120px;
} }
.max-container { .max-container {
+2
View File
@@ -9,6 +9,7 @@ export type ListProductsParams = {
q?: string q?: string
categoryId?: string categoryId?: string
categorySlug?: string categorySlug?: string
tag?: string
minPrice?: number minPrice?: number
maxPrice?: number maxPrice?: number
page?: number page?: number
@@ -112,6 +113,7 @@ function buildQuery(params: ListProductsParams) {
if (params.q?.trim()) search.set('q', params.q.trim()) if (params.q?.trim()) search.set('q', params.q.trim())
if (params.categoryId) search.set('categoryId', params.categoryId) if (params.categoryId) search.set('categoryId', params.categoryId)
if (params.categorySlug) search.set('categorySlug', params.categorySlug) 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.minPrice !== undefined) search.set('minPrice', String(params.minPrice))
if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice)) if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
if (params.page) search.set('page', String(params.page)) 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 intro?: string | null
description?: string | null description?: string | null
tags?: string[] tags?: string[]
preparationHours?: number | null
createdAt?: string
mainImageUrl: string | null mainImageUrl: string | null
mainImageKey?: string | null mainImageKey?: string | null
gallery?: ProductGalleryImage[] gallery?: ProductGalleryImage[]
+26 -18
View File
@@ -165,14 +165,9 @@
position: relative; position: relative;
z-index: 1; z-index: 1;
overflow: visible; overflow: visible;
}
.sohan-section__top {
display: flex; display: flex;
align-items: flex-end; align-items: center;
gap: 12px; gap: 28px;
margin-bottom: 20px;
overflow: visible;
} }
.sohan-section__media { .sohan-section__media {
@@ -207,10 +202,18 @@
user-select: none; 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 { .sohan-section__title {
width: 100%; width: 100%;
min-width: 0;
z-index: 2;
} }
.sohan-section__text { .sohan-section__text {
@@ -218,8 +221,7 @@
line-height: 1.8; line-height: 1.8;
text-align: justify; text-align: justify;
color: var(--color-secondary); color: var(--color-secondary);
position: relative; margin: 0;
z-index: 2;
} }
/* Offers wrapper */ /* Offers wrapper */
@@ -377,10 +379,11 @@
margin-top: 120px; margin-top: 120px;
} }
.sohan-section__top { .sohan-section__inner {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 12px;
text-align: center;
} }
.sohan-section__media { .sohan-section__media {
@@ -393,6 +396,11 @@
width: 110%; width: 110%;
} }
.sohan-section__content {
align-items: center;
width: 100%;
}
.sohan-section__title .header-title__placeholder { .sohan-section__title .header-title__placeholder {
font-size: clamp(44px, 10vw, 72px); font-size: clamp(44px, 10vw, 72px);
} }
@@ -402,6 +410,10 @@
transform: none; transform: none;
} }
.sohan-section__text {
text-align: center;
}
.home-footer-block { .home-footer-block {
margin-top: 200px; margin-top: 200px;
} }
@@ -466,11 +478,7 @@
.sohan-section__inner { .sohan-section__inner {
padding-inline: 0; padding-inline: 0;
} gap: 8px;
.sohan-section__top {
gap: 0;
margin-bottom: 12px;
} }
.sohan-section__media { .sohan-section__media {
+80 -2
View File
@@ -2,6 +2,13 @@ import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import HeaderTitle from '../components/HeaderTitle' 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 macaronRight from '../assets/images/brown-macaron-right.png'
import macaronLeft from '../assets/images/brown-macaron-left.png' import macaronLeft from '../assets/images/brown-macaron-left.png'
import chocolateCake from '../assets/images/chocolate-cake.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 peste from '../assets/images/peste.png'
import './HomePage.css' 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 = [ const DECOR_CARDS = [
{ {
title: 'انواع', title: 'انواع',
@@ -58,11 +72,66 @@ const OFFER_CATEGORIES = [
export default function HomePage() { export default function HomePage() {
const [openOffer, setOpenOffer] = useState(0) const [openOffer, setOpenOffer] = useState(0)
const [carousels, setCarousels] = useState<CarouselSection[]>([])
const [carouselsLoading, setCarouselsLoading] = useState(true)
useEffect(() => { useEffect(() => {
document.title = 'شیرینی بلوط - صفحه اصلی' 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 ( return (
<div className="home"> <div className="home">
{/* Hero title */} {/* Hero title */}
@@ -89,6 +158,8 @@ export default function HomePage() {
</div> </div>
</div> </div>
{renderCarousel(slotted[0])}
{/* For The Best */} {/* For The Best */}
<HeaderTitle <HeaderTitle
placeholder="For The Best" placeholder="For The Best"
@@ -130,6 +201,8 @@ export default function HomePage() {
</div> </div>
</div> </div>
{renderCarousel(slotted[1])}
{/* About */} {/* About */}
<div className="max-container about-section"> <div className="max-container about-section">
<div className="about-section__image"> <div className="about-section__image">
@@ -152,15 +225,17 @@ export default function HomePage() {
</div> </div>
</div> </div>
{renderCarousel(slotted[2])}
{/* Sohan */} {/* Sohan */}
<section className="sohan-section"> <section className="sohan-section">
<img src={peste} alt="" className="sohan-section__peste" /> <img src={peste} alt="" className="sohan-section__peste" />
<div className="sohan-section__inner"> <div className="sohan-section__inner">
<div className="sohan-section__top">
<div className="sohan-section__media"> <div className="sohan-section__media">
<img src={sohan} alt="سوهان" className="sohan-section__image" /> <img src={sohan} alt="سوهان" className="sohan-section__image" />
<img src={sohanPattern} alt="" className="sohan-section__pattern" /> <img src={sohanPattern} alt="" className="sohan-section__pattern" />
</div> </div>
<div className="sohan-section__content">
<HeaderTitle <HeaderTitle
placeholder="Sohan" placeholder="Sohan"
secondary="سوهان" secondary="سوهان"
@@ -169,15 +244,18 @@ export default function HomePage() {
align="start" align="start"
className="sohan-section__title" className="sohan-section__title"
/> />
</div>
<p className="sohan-section__text"> <p className="sohan-section__text">
سوهان یکی از اصیل‌ترین سوغات قم است که در شیرینی‌سرای بلوط با استفاده از مواد اولیه سوهان یکی از اصیل‌ترین سوغات قم است که در شیرینی‌سرای بلوط با استفاده از مواد اولیه
باکیفیت و تازه تهیه می‌شود. عطر دل‌انگیز کره و زعفران در کنار بافتی لطیف و طعمی باکیفیت و تازه تهیه می‌شود. عطر دل‌انگیز کره و زعفران در کنار بافتی لطیف و طعمی
ماندگار، سوهان بلوط را به انتخابی مناسب برای پذیرایی و هدیه تبدیل کرده است. ماندگار، سوهان بلوط را به انتخابی مناسب برای پذیرایی و هدیه تبدیل کرده است.
</p> </p>
</div> </div>
</div>
</section> </section>
{renderCarousel(slotted[3])}
{overflow.map((section) => renderCarousel(section))}
{/* Special offers + hours */} {/* Special offers + hours */}
<div className="home-footer-block"> <div className="home-footer-block">
<section className="offers-wrapper"> <section className="offers-wrapper">
+77 -2
View File
@@ -69,6 +69,10 @@
align-items: start; align-items: start;
} }
.product-images--single {
grid-template-columns: 1fr;
}
.product-images__main img, .product-images__main img,
.product-images__thumbs img { .product-images__thumbs img {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
@@ -78,6 +82,34 @@
border-radius: 16px; 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 { .product-images__thumbs {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -92,10 +124,12 @@
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, opacity 0.2s ease;
} }
.product-images__thumbs button.is-active { .product-images__thumbs button:hover {
border-color: var(--color-primary); border-color: rgba(143, 65, 12, 0.35);
opacity: 0.9;
} }
.product--name { .product--name {
@@ -152,6 +186,47 @@
margin: 0; 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 { .product-detail__cart-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
+103 -27
View File
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' 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 PageHeader from '../components/PageHeader'
import ProductCard from '../components/ProductCard' import ProductCard from '../components/ProductCard'
import ProductOptionsModal from '../components/ProductOptionsModal' import ProductOptionsModal from '../components/ProductOptionsModal'
import ImageLightbox from '../components/ImageLightbox'
import { getProduct, listProducts } from '../lib/api' import { getProduct, listProducts } from '../lib/api'
import { addToCart } from '../lib/cart' import { addToCart } from '../lib/cart'
import type { Product, ProductOptionValue } from '../lib/types' import type { Product, ProductOptionValue } from '../lib/types'
import { formatProductPrice, productImageSrc } from '../lib/types' import { formatProductPrice, formatPriceFa, productImageSrc } from '../lib/types'
import handbagIcon from '../assets/images/Handbag.svg'
import cakeIcon from '../assets/images/Cake.svg' import cakeIcon from '../assets/images/Cake.svg'
import defaultPhoto from '../assets/images/DefaultPhoto.png' import defaultPhoto from '../assets/images/DefaultPhoto.png'
import './ProductDetailPage.css' import './ProductDetailPage.css'
@@ -21,16 +21,34 @@ function stripHtml(value: string) {
.trim() .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() { export default function ProductDetailPage() {
const { slugOrId = '', id = '' } = useParams() const { slugOrId = '', id = '' } = useParams()
const productId = id || slugOrId const productId = id || slugOrId
const [product, setProduct] = useState<Product | null>(null) const [product, setProduct] = useState<Product | null>(null)
const [related, setRelated] = useState<Product[]>([]) const [related, setRelated] = useState<Product[]>([])
const [activeImage, setActiveImage] = useState<string>('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [quantity, setQuantity] = useState(1) const [quantity, setQuantity] = useState(1)
const [optionsOpen, setOptionsOpen] = useState(false) const [optionsOpen, setOptionsOpen] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
useEffect(() => { useEffect(() => {
document.title = 'شیرینی بلوط - صفحه جزئیات محصول' document.title = 'شیرینی بلوط - صفحه جزئیات محصول'
@@ -43,16 +61,12 @@ export default function ProductDetailPage() {
setError(null) setError(null)
setProduct(null) setProduct(null)
setRelated([]) setRelated([])
setLightboxOpen(false)
getProduct(productId) getProduct(productId)
.then((item) => { .then((item) => {
if (cancelled) return if (cancelled) return
setProduct(item) setProduct(item)
const galleryUrls = [
item.mainImageUrl,
...(item.gallery ?? []).map((g) => g.url),
].filter(Boolean) as string[]
setActiveImage(galleryUrls[0] || defaultPhoto)
setLoading(false) setLoading(false)
listProducts({ listProducts({
@@ -79,18 +93,21 @@ export default function ProductDetailPage() {
} }
}, [productId]) }, [productId])
const thumbs = useMemo(() => { const mainImage = product ? productImageSrc(product, defaultPhoto) : defaultPhoto
const galleryImages = useMemo(() => {
if (!product) return [] if (!product) return []
const urls = [ const main = product.mainImageUrl || ''
product.mainImageUrl, return uniqueUrls((product.gallery ?? []).map((g) => g.url)).filter((url) => url !== main)
...(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)
}, [product]) }, [product])
const lightboxImages = useMemo(
() => uniqueUrls([mainImage, ...galleryImages]),
[mainImage, galleryImages],
)
const hasGallery = galleryImages.length > 0
const price = product ? formatProductPrice(product) : null const price = product ? formatProductPrice(product) : null
const abstractText = product?.intro ? stripHtml(product.intro) : '' const abstractText = product?.intro ? stripHtml(product.intro) : ''
const descriptionHtml = product?.description?.trim() || '' const descriptionHtml = product?.description?.trim() || ''
@@ -120,6 +137,12 @@ export default function ProductDetailPage() {
commitAdd() commitAdd()
} }
function openLightbox(index = 0) {
if (lightboxImages.length === 0) return
setLightboxIndex(index)
setLightboxOpen(true)
}
useEffect(() => { useEffect(() => {
setQuantity(product?.sellUnit === 'kilo' ? 0.5 : 1) setQuantity(product?.sellUnit === 'kilo' ? 0.5 : 1)
setOptionsOpen(false) setOptionsOpen(false)
@@ -135,7 +158,7 @@ export default function ProductDetailPage() {
</div> </div>
<PageHeader <PageHeader
icon={handbagIcon} icon={cakeIcon}
placeholder="Product Details" placeholder="Product Details"
secondary="جزئیات" secondary="جزئیات"
primary="محصــــول ما" primary="محصــــول ما"
@@ -166,22 +189,42 @@ export default function ProductDetailPage() {
{!loading && product && price && ( {!loading && product && price && (
<> <>
<div className="product-detail__grid"> <div className="product-detail__grid">
<div className="product-images"> <div
className={`product-images${hasGallery ? '' : ' product-images--single'}`}
>
<div className="product-images__main"> <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 <button
key={`${url}-${index}`}
type="button" type="button"
className={activeImage === url ? 'is-active' : undefined} className="product-images__main-btn"
onClick={() => setActiveImage(url)} 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="" /> <img src={url} alt="" />
</button> </button>
))} ))}
</div> </div>
)}
</div> </div>
<div className="product-detail__info"> <div className="product-detail__info">
@@ -215,6 +258,29 @@ export default function ProductDetailPage() {
</div> </div>
</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="product-detail__cart-wrap">
<div className="qty-stepper" dir="ltr"> <div className="qty-stepper" dir="ltr">
<button <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 && ( {descriptionHtml && (
<section className="product-detail__description"> <section className="product-detail__description">
<div className="caption__title">توضیحات محصول :</div> <div className="caption__title">توضیحات محصول :</div>