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:
co-authored by
Cursor
parent
35939e6ee4
commit
6c060e222c
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -101,6 +101,7 @@ button {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.max-container {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user