+
+ {price.amount}
+ تومان
+ /{price.unitLabel}
+
diff --git a/src/components/ProductCarousel.css b/src/components/ProductCarousel.css
new file mode 100644
index 0000000..c5825b7
--- /dev/null
+++ b/src/components/ProductCarousel.css
@@ -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;
+ }
+}
diff --git a/src/components/ProductCarousel.tsx b/src/components/ProductCarousel.tsx
new file mode 100644
index 0000000..62d4ad8
--- /dev/null
+++ b/src/components/ProductCarousel.tsx
@@ -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
(null)
+
+ function scrollByCard(direction: 1 | -1) {
+ const el = scrollerRef.current
+ if (!el) return
+ const card = el.querySelector('[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 (
+
+
+ {loading ? (
+
+ در حال بارگذاری محصولات...
+
+ ) : (
+
+
+
+ {viewAllTo && (
+
+ مشاهده همه
+
+ )}
+
+
+
+
scrollByCard(-1)}
+ aria-label="قبلی"
+ >
+
+
+
+
+ {products.map((product) => (
+
+ ))}
+
+
+
scrollByCard(1)}
+ aria-label="بعدی"
+ >
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/src/index.css b/src/index.css
index 623844c..7f2808d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -101,6 +101,7 @@ button {
min-height: 100vh;
background: var(--color-bg);
overflow-x: hidden;
+ padding-bottom: 120px;
}
.max-container {
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 1dc9d5b..20a023a 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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))
diff --git a/src/lib/homeCarousels.ts b/src/lib/homeCarousels.ts
new file mode 100644
index 0000000..4495a7d
--- /dev/null
+++ b/src/lib/homeCarousels.ts
@@ -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)
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index f259bc2..0e6f583 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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[]
diff --git a/src/pages/HomePage.css b/src/pages/HomePage.css
index 3e47090..9b5341c 100644
--- a/src/pages/HomePage.css
+++ b/src/pages/HomePage.css
@@ -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 {
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 79512b0..bb19d2a 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -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([])
+ 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 (
+
+ )
+ }
+
return (
{/* Hero title */}
@@ -89,6 +158,8 @@ export default function HomePage() {