Add special order page and home entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-12 23:09:30 +03:30
co-authored by Cursor
parent 458475abe6
commit 389fc6fc51
9 changed files with 927 additions and 8 deletions
+6 -6
View File
@@ -2,9 +2,9 @@
Public storefront for شیرینی‌فروشی بلوط. React + Vite SPA that browses products and places orders via the NestJS API. Design matched to [shirinibalout.com](https://www.shirinibalout.com/). Public storefront for شیرینی‌فروشی بلوط. React + Vite SPA that browses products and places orders via the NestJS API. Design matched to [shirinibalout.com](https://www.shirinibalout.com/).
Repo: `https://git.meshkee.com/BaloutPastry/website.git` Repo: `https://gitea.meshkee.com/balout-pastry/website.git`
API repo: `https://git.meshkee.com/BaloutPastry/backend.git` API repo: `https://gitea.meshkee.com/balout-pastry/backend.git`
Dashboards (customer login / account): `https://git.meshkee.com/BaloutPastry/dashboards.git` Dashboards (customer login / account): `https://gitea.meshkee.com/balout-pastry/dashboards.git`
## Stack ## Stack
@@ -25,7 +25,7 @@ Dashboards (customer login / account): `https://git.meshkee.com/BaloutPastry/das
## Setup on a new device ## Setup on a new device
```bash ```bash
git clone https://git.meshkee.com/BaloutPastry/website.git git clone https://gitea.meshkee.com/balout-pastry/website.git
cd website cd website
cp .env.example .env cp .env.example .env
@@ -151,5 +151,5 @@ Set `VITE_*` **before** `npm run build` (they are baked into the bundle).
## Related ## Related
- Backend setup: clone `BaloutPastry/backend` and read `CONTEXT.md` - Backend setup: clone `balout-pastry/backend` and read `CONTEXT.md`
- Customer / admin UI: clone `BaloutPastry/dashboards` and read `CONTEXT.md` - Customer / admin UI: clone `balout-pastry/dashboards` and read `CONTEXT.md`
+2
View File
@@ -10,6 +10,7 @@ import ContactUsPage from './pages/ContactUsPage'
import AboutUsPage from './pages/AboutUsPage' import AboutUsPage from './pages/AboutUsPage'
import ProductsListPage from './pages/ProductsListPage' import ProductsListPage from './pages/ProductsListPage'
import ProductsSlugOrDetailPage from './pages/ProductsSlugOrDetailPage' import ProductsSlugOrDetailPage from './pages/ProductsSlugOrDetailPage'
import SpecialOrderPage from './pages/SpecialOrderPage'
export default function App() { export default function App() {
return ( return (
@@ -21,6 +22,7 @@ export default function App() {
<Route path="/quick-info" element={<QuickInfoPage />} /> <Route path="/quick-info" element={<QuickInfoPage />} />
<Route path="/contact-us" element={<ContactUsPage />} /> <Route path="/contact-us" element={<ContactUsPage />} />
<Route path="/about-us" element={<AboutUsPage />} /> <Route path="/about-us" element={<AboutUsPage />} />
<Route path="/special-order" element={<SpecialOrderPage />} />
<Route path="/products" element={<ProductsListPage />} /> <Route path="/products" element={<ProductsListPage />} />
<Route path="/products/:slugOrId" element={<ProductsSlugOrDetailPage />} /> <Route path="/products/:slugOrId" element={<ProductsSlugOrDetailPage />} />
<Route path="*" element={<HomePage />} /> <Route path="*" element={<HomePage />} />
+1
View File
@@ -18,6 +18,7 @@ import './Header.css'
const NAV_LINKS = [ const NAV_LINKS = [
{ to: '/', label: 'صفحه اصلی' }, { to: '/', label: 'صفحه اصلی' },
{ to: '/products', label: 'محصولات', hasDropdown: true }, { to: '/products', label: 'محصولات', hasDropdown: true },
{ to: '/special-order', label: 'سفارش ویژه' },
{ to: '/about-us', label: 'درباره ما' }, { to: '/about-us', label: 'درباره ما' },
{ to: '/contact-us', label: 'تماس با ما' }, { to: '/contact-us', label: 'تماس با ما' },
{ to: '/quick-info', label: 'اطلاعات مجموعه' }, { to: '/quick-info', label: 'اطلاعات مجموعه' },
+135 -2
View File
@@ -1,4 +1,9 @@
import { getAccessToken } from './auth' import {
clearSession,
getAccessToken,
getRefreshToken,
setSession,
} from './auth'
import type { CategoryNode, Product, ProductListResponse } from './types' import type { CategoryNode, Product, ProductListResponse } from './types'
const API_BASE = const API_BASE =
@@ -64,6 +69,40 @@ export type CreateMyOrderPayload = {
}> }>
} }
export type SpecialOrderCategory = 'specialCake' | 'specialPastry'
export type SpecialOrderImageInput = {
url: string
storageKey: string
}
export type CreateMySpecialOrderPayload = {
category: SpecialOrderCategory
name: string
description?: string
images?: SpecialOrderImageInput[]
}
export type MediaUploadResult = {
url: string
storageKey: string
storageDisk: string
}
export type SpecialOrder = {
id: string
category: SpecialOrderCategory
name: string
description: string
createdAt: string
images: Array<{
id: string
url: string
storageKey: string
sortOrder: number
}>
}
async function parseError(response: Response) { async function parseError(response: Response) {
const text = await response.text().catch(() => '') const text = await response.text().catch(() => '')
try { try {
@@ -76,7 +115,51 @@ async function parseError(response: Response) {
return text || `Request failed (${response.status})` return text || `Request failed (${response.status})`
} }
async function apiGet<T>(path: string, auth = false): Promise<T> { let refreshInFlight: Promise<boolean> | null = null
async function tryRefreshSession(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
const refreshToken = getRefreshToken()
if (!refreshToken) {
clearSession()
return false
}
try {
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.accessToken || !payload?.refreshToken) {
clearSession()
return false
}
setSession({
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
user: payload.user,
})
return true
} catch {
clearSession()
return false
} finally {
refreshInFlight = null
}
})()
return refreshInFlight
}
async function apiGet<T>(
path: string,
auth = false,
skipRefresh = false,
): Promise<T> {
const headers: HeadersInit = {} const headers: HeadersInit = {}
if (auth) { if (auth) {
const token = getAccessToken() const token = getAccessToken()
@@ -84,6 +167,12 @@ async function apiGet<T>(path: string, auth = false): Promise<T> {
headers.Authorization = `Bearer ${token}` headers.Authorization = `Bearer ${token}`
} }
const response = await fetch(`${API_BASE}${path}`, { headers }) const response = await fetch(`${API_BASE}${path}`, { headers })
if (response.status === 401 && auth && !skipRefresh) {
const refreshed = await tryRefreshSession()
if (refreshed) return apiGet<T>(path, auth, true)
}
if (!response.ok) throw new Error(await parseError(response)) if (!response.ok) throw new Error(await parseError(response))
return response.json() as Promise<T> return response.json() as Promise<T>
} }
@@ -92,6 +181,7 @@ async function apiSend<T>(
path: string, path: string,
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown, body?: unknown,
skipRefresh = false,
): Promise<T> { ): Promise<T> {
const token = getAccessToken() const token = getAccessToken()
if (!token) throw new Error('لطفاً وارد حساب کاربری شوید') if (!token) throw new Error('لطفاً وارد حساب کاربری شوید')
@@ -103,6 +193,12 @@ async function apiSend<T>(
}, },
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
}) })
if (response.status === 401 && !skipRefresh) {
const refreshed = await tryRefreshSession()
if (refreshed) return apiSend<T>(path, method, body, true)
}
if (!response.ok) throw new Error(await parseError(response)) if (!response.ok) throw new Error(await parseError(response))
if (response.status === 204) return undefined as T if (response.status === 204) return undefined as T
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -170,3 +266,40 @@ export function createMyOrder(payload: CreateMyOrderPayload) {
payload, payload,
) )
} }
export async function uploadMedia(
kind: 'specialOrder' | 'temp',
file: Blob | File,
fileName?: string,
skipRefresh = false,
) {
const token = getAccessToken()
if (!token) throw new Error('لطفاً وارد حساب کاربری شوید')
const formData = new FormData()
const name =
fileName ??
(file instanceof File && file.name ? file.name : `upload-${kind}.jpg`)
formData.append('file', file, name)
const response = await fetch(
`${API_BASE}/media/upload?kind=${encodeURIComponent(kind)}`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
},
)
if (response.status === 401 && !skipRefresh) {
const refreshed = await tryRefreshSession()
if (refreshed) return uploadMedia(kind, file, fileName, true)
}
if (!response.ok) throw new Error(await parseError(response))
return response.json() as Promise<MediaUploadResult>
}
export function createMySpecialOrder(payload: CreateMySpecialOrderPayload) {
return apiSend<SpecialOrder>('/special-orders/mine', 'POST', payload)
}
+4
View File
@@ -105,6 +105,10 @@ export function getAccessToken(): string | null {
return getSession()?.accessToken ?? null return getSession()?.accessToken ?? null
} }
export function getRefreshToken(): string | null {
return getSession()?.refreshToken ?? null
}
export function isAuthenticated(): boolean { export function isAuthenticated(): boolean {
return getSession() !== null return getSession() !== null
} }
+77
View File
@@ -141,6 +141,49 @@
color: var(--color-secondary); color: var(--color-secondary);
} }
/* Special order promo */
.special-order-home {
margin-top: 100px;
position: relative;
}
.special-order-home__inner {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 40px;
align-items: center;
}
.special-order-home__title {
margin-bottom: 0;
}
.special-order-home__text {
margin-top: 24px;
margin-bottom: 0;
font-size: clamp(13px, 3vw, 16px);
line-height: clamp(20px, 6vw, 25px);
text-align: justify;
color: var(--color-secondary);
}
.special-order-home__cta {
margin-top: 28px;
}
.special-order-home__media {
display: flex;
justify-content: center;
align-items: center;
}
.special-order-home__media img {
width: 100%;
max-width: 420px;
height: auto;
object-fit: contain;
}
/* Sohan */ /* Sohan */
.sohan-section { .sohan-section {
margin-top: 200px; margin-top: 200px;
@@ -375,6 +418,24 @@
justify-content: center; justify-content: center;
} }
.special-order-home {
margin-top: 64px;
}
.special-order-home__inner {
grid-template-columns: 1fr;
gap: 20px;
text-align: center;
}
.special-order-home__text {
text-align: center;
}
.special-order-home__cta {
margin-inline: auto;
}
.sohan-section { .sohan-section {
margin-top: 120px; margin-top: 120px;
} }
@@ -472,6 +533,22 @@
margin-top: 12px; margin-top: 12px;
} }
.special-order-home {
margin-top: 56px;
}
.special-order-home__text {
margin-top: 12px;
}
.special-order-home__media img {
max-width: 260px;
}
.special-order-home__cta {
margin-top: 20px;
}
.sohan-section { .sohan-section {
margin-top: 72px; margin-top: 72px;
} }
+28
View File
@@ -225,6 +225,34 @@ export default function HomePage() {
</div> </div>
</div> </div>
{/* Special order promo */}
<section className="special-order-home">
<div className="max-container special-order-home__inner">
<div className="special-order-home__content">
<HeaderTitle
placeholder="Custom Order"
secondary="سفارش"
primary="ویــژه بلوط"
size="lg"
align="start"
className="special-order-home__title"
/>
<p className="special-order-home__text">
کیک تولد با تم دلخواه، شیرینی مجلسی یا هر ایدهٔ خاصی که در ذهن دارید را برای ما
بنویسید. بدون پرداخت آنلاین، فقط درخواستتان را ثبت کنید تا تیم بلوط برای هماهنگی
با شما تماس بگیرد.
</p>
<Link to="/special-order" className="btn-primary special-order-home__cta">
ثبت سفارش ویژه
<ChevronLeft size={14} />
</Link>
</div>
<div className="special-order-home__media">
<img src={deliciousDecorate} alt="کیک و شیرینی سفارشی" />
</div>
</div>
</section>
{renderCarousel(slotted[2])} {renderCarousel(slotted[2])}
{/* Sohan */} {/* Sohan */}
+315
View File
@@ -0,0 +1,315 @@
.special-order-page__body {
margin-bottom: 48px;
}
.special-order-page__grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 40px;
align-items: start;
}
.special-order-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.special-order-form__alert {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(155, 44, 44, 0.2);
background: rgba(155, 44, 44, 0.06);
color: #9b2c2c;
font-size: 0.9rem;
line-height: 1.7;
}
.special-order-form__alert--ok {
border-color: rgba(47, 107, 79, 0.22);
background: rgba(47, 107, 79, 0.08);
color: #2f6b4f;
}
.special-order-field {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
border: none;
}
.special-order-field > span {
font-size: 12px;
color: var(--color-secondary);
padding-inline: 4px;
}
.special-order-field input,
.special-order-field textarea {
width: 100%;
border: 1px solid var(--color-gray-100);
border-radius: 12px;
background: transparent;
padding: 12px 14px;
font-family: inherit;
font-size: 15px;
color: #000;
outline: none;
resize: vertical;
transition: border-color 0.2s ease;
}
.special-order-field input:focus,
.special-order-field textarea:focus {
border-color: var(--color-primary);
}
.special-order-field--textarea textarea {
min-height: 160px;
}
.special-order-radios {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.special-order-radio {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid var(--color-gray-100);
cursor: pointer;
user-select: none;
font-size: 0.92rem;
transition:
border-color 0.2s ease,
color 0.2s ease;
}
.special-order-radio input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.special-order-radio:has(input:checked) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.special-order-gallery {
display: flex;
flex-direction: column;
gap: 12px;
}
.special-order-gallery__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--color-secondary);
padding-inline: 4px;
}
.special-order-gallery__add {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--color-gray-100);
font-size: 0.82rem;
cursor: pointer;
color: var(--color-secondary);
transition:
color 0.2s ease,
border-color 0.2s ease;
}
.special-order-gallery__add:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.special-order-gallery__empty {
margin: 0;
padding: 24px 16px;
text-align: center;
font-size: 0.85rem;
line-height: 1.7;
color: var(--color-secondary);
border-radius: 12px;
border: 1px dashed var(--color-gray-100);
}
.special-order-gallery__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.special-order-gallery__item {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--color-gray-100);
background: #fff;
}
.special-order-gallery__item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.special-order-gallery__remove {
position: absolute;
top: 6px;
inset-inline-start: 6px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(42, 24, 16, 0.65);
color: white;
border: none;
cursor: pointer;
}
.special-order-form__submit {
display: flex;
justify-content: center;
margin-top: 8px;
}
.special-order-form__submit .btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.special-order-gate {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 28px 24px;
border-radius: 16px;
border: 1px solid var(--color-gray-100);
background: rgba(255, 255, 255, 0.45);
}
.special-order-gate__icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--color-primary);
background: rgba(143, 65, 12, 0.08);
}
.special-order-gate__title {
margin: 0;
font-size: clamp(20px, 3vw, 28px);
font-weight: 700;
color: var(--color-primary);
}
.special-order-gate__text {
margin: 0;
font-size: 0.95rem;
line-height: 1.9;
color: var(--color-secondary);
text-align: justify;
}
.special-order-aside {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 4px;
}
.special-order-aside__badge {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
color: var(--color-primary);
font-size: 0.9rem;
font-weight: 500;
}
.special-order-aside__lead {
margin: 0;
font-size: clamp(15px, 2.4vw, 18px);
line-height: 1.9;
color: var(--color-secondary);
}
.special-order-aside__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.special-order-aside__list li {
display: flex;
align-items: flex-start;
gap: 10px;
}
.special-order-aside__list .star {
color: var(--color-primary);
line-height: 1.6;
flex-shrink: 0;
}
.special-order-aside__list strong {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: #000;
margin-bottom: 4px;
}
.special-order-aside__list p {
margin: 0;
font-size: 0.85rem;
line-height: 1.8;
color: var(--color-secondary);
}
@media (max-width: 900px) {
.special-order-page__grid {
grid-template-columns: 1fr;
gap: 32px;
}
.special-order-aside {
order: -1;
}
}
@media (max-width: 640px) {
.special-order-gallery__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+359
View File
@@ -0,0 +1,359 @@
import { useEffect, useState, type FormEvent } from 'react'
import { ChevronLeft, ImagePlus, LogIn, Sparkles, X } from 'lucide-react'
import PageHeader from '../components/PageHeader'
import OffersHours from '../components/OffersHours'
import cakeIcon from '../assets/images/Cake.svg'
import {
createMySpecialOrder,
uploadMedia,
type SpecialOrderCategory,
} from '../lib/api'
import {
consumeAuthHashFromUrl,
getAuthUser,
getCustomerLoginUrl,
isAuthenticated,
type AuthUser,
} from '../lib/auth'
import './SpecialOrderPage.css'
const CATEGORIES: { id: SpecialOrderCategory; label: string }[] = [
{ id: 'specialCake', label: 'کیک ویژه' },
{ id: 'specialPastry', label: 'شیرینی ویژه' },
]
const SUBMIT_COOLDOWN_MS = 60_000
const COOLDOWN_STORAGE_KEY = 'balout.specialOrderCooldownUntil'
const SIDE_NOTES = [
{
title: 'بدون پرداخت آنلاین',
text: 'این فرم فقط برای ثبت درخواست است؛ هزینه بعداً با هماهنگی فروشگاه مشخص می‌شود.',
},
{
title: 'کیک یا شیرینی سفارشی',
text: 'ایده، تم تولد، تعداد و زمان تحویل را در توضیحات بنویسید تا دقیق‌تر راهنمایی شوید.',
},
{
title: 'تصاویر ایده',
text: 'اگر نمونه یا طرحی دارید، در گالری اضافه کنید تا ساخت سفارش نزدیک‌تر به سلیقه شما باشد.',
},
]
async function dataUrlToBlob(dataUrl: string) {
const response = await fetch(dataUrl)
return response.blob()
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function readCooldownUntil() {
try {
const raw = sessionStorage.getItem(COOLDOWN_STORAGE_KEY)
if (!raw) return 0
const value = Number(raw)
return Number.isFinite(value) ? value : 0
} catch {
return 0
}
}
function writeCooldownUntil(until: number) {
try {
sessionStorage.setItem(COOLDOWN_STORAGE_KEY, String(until))
} catch {
// ignore
}
}
function cooldownSecondsLeft(until = readCooldownUntil()) {
return Math.max(0, Math.ceil((until - Date.now()) / 1000))
}
export default function SpecialOrderPage() {
const [user, setUser] = useState<AuthUser | null>(() => getAuthUser())
const [category, setCategory] = useState<SpecialOrderCategory>('specialCake')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [gallery, setGallery] = useState<string[]>([])
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [saving, setSaving] = useState(false)
const [cooldownLeft, setCooldownLeft] = useState(() => cooldownSecondsLeft())
useEffect(() => {
document.title = 'شیرینی بلوط - سفارش ویژه'
consumeAuthHashFromUrl()
setUser(getAuthUser())
}, [])
useEffect(() => {
if (cooldownLeft <= 0) return
const timer = window.setInterval(() => {
const left = cooldownSecondsLeft()
setCooldownLeft(left)
if (left <= 0) window.clearInterval(timer)
}, 250)
return () => window.clearInterval(timer)
}, [cooldownLeft])
const authenticated = Boolean(user) || isAuthenticated()
const loginUrl = getCustomerLoginUrl(
`${window.location.origin}/special-order`,
)
const submitDisabled = saving || cooldownLeft > 0
async function handleFiles(event: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
if (files.length === 0) return
const loaded = await Promise.all(files.map((file) => readFileAsDataUrl(file)))
setGallery((current) => [...current, ...loaded])
}
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError('')
setSuccess('')
if (!authenticated) {
window.location.assign(loginUrl)
return
}
if (cooldownLeft > 0) {
setError(`لطفاً ${cooldownLeft} ثانیه دیگر دوباره تلاش کنید`)
return
}
if (!name.trim()) {
setError('نام سفارش الزامی است')
return
}
setSaving(true)
try {
const images = []
for (const src of gallery) {
const blob = await dataUrlToBlob(src)
const uploaded = await uploadMedia('specialOrder', blob)
images.push({ url: uploaded.url, storageKey: uploaded.storageKey })
}
await createMySpecialOrder({
category,
name: name.trim(),
description: description.trim(),
images,
})
setName('')
setDescription('')
setGallery([])
setCategory('specialCake')
setSuccess(
'سفارش ویژه شما ثبت شد. تیم بلوط به‌زودی برای هماهنگی با شما تماس می‌گیرد.',
)
const until = Date.now() + SUBMIT_COOLDOWN_MS
writeCooldownUntil(until)
setCooldownLeft(cooldownSecondsLeft(until))
} catch (err) {
const raw = err instanceof Error ? err.message : 'ثبت سفارش ویژه ناموفق بود'
const message =
/unauthorized/i.test(raw)
? 'نشست شما منقضی شده است. لطفاً دوباره وارد شوید.'
: raw
setError(message)
} finally {
setSaving(false)
}
}
return (
<div className="special-order-page">
<PageHeader
icon={cakeIcon}
placeholder="Special Order"
secondary="سفارش"
primary="ویــژه شما"
crumbs={[
{ label: 'صفحه اصلی', to: '/' },
{ label: 'سفارش ویژه' },
]}
/>
<div className="max-container special-order-page__body">
<div className="special-order-page__grid">
{authenticated ? (
<form
className="special-order-form"
onSubmit={(e) => void handleSubmit(e)}
>
{error ? (
<div className="special-order-form__alert" role="alert">
{error}
</div>
) : null}
{success ? (
<div
className="special-order-form__alert special-order-form__alert--ok"
role="status"
>
{success}
</div>
) : null}
<div className="special-order-field">
<span>دسته‌بندی</span>
<div className="special-order-radios" role="radiogroup">
{CATEGORIES.map((item) => (
<label key={item.id} className="special-order-radio">
<input
type="radio"
name="special-order-category"
value={item.id}
checked={category === item.id}
onChange={() => setCategory(item.id)}
disabled={saving}
/>
<span>{item.label}</span>
</label>
))}
</div>
</div>
<label className="special-order-field">
<span>نام سفارش</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً کیک تولد با تم خاص"
disabled={saving}
/>
</label>
<label className="special-order-field special-order-field--textarea">
<span>توضیحات</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="جزئیات، سلیقه، تعداد، زمان تحویل و ..."
rows={6}
disabled={saving}
/>
</label>
<div className="special-order-gallery">
<div className="special-order-gallery__header">
<span>گالری تصاویر</span>
<label className="special-order-gallery__add">
<ImagePlus size={16} />
افزودن تصویر
<input
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => void handleFiles(e)}
disabled={saving}
/>
</label>
</div>
{gallery.length === 0 ? (
<p className="special-order-gallery__empty">
در صورت تمایل، نمونه یا ایده تصویری اضافه کنید.
</p>
) : (
<div className="special-order-gallery__grid">
{gallery.map((src, index) => (
<div
key={`${index}-${src.slice(0, 24)}`}
className="special-order-gallery__item"
>
<img src={src} alt={`تصویر ${index + 1}`} />
<button
type="button"
className="special-order-gallery__remove"
aria-label="حذف تصویر"
disabled={saving}
onClick={() =>
setGallery((current) =>
current.filter((_, i) => i !== index),
)
}
>
<X size={14} />
</button>
</div>
))}
</div>
)}
</div>
<div className="special-order-form__submit">
<button
type="submit"
className="btn-primary"
disabled={submitDisabled}
>
{saving
? 'در حال ثبت...'
: cooldownLeft > 0
? `ثبت مجدد تا ${cooldownLeft.toLocaleString('fa-IR')} ثانیه`
: 'ثبت سفارش ویژه'}
{!submitDisabled ? <ChevronLeft size={14} /> : null}
</button>
</div>
</form>
) : (
<div className="special-order-gate">
<div className="special-order-gate__icon" aria-hidden>
<LogIn size={28} strokeWidth={1.5} />
</div>
<h2 className="special-order-gate__title">برای ثبت سفارش ویژه وارد شوید</h2>
<p className="special-order-gate__text">
ثبت درخواست کیک و شیرینی سفارشی فقط برای کاربران واردشده امکان‌پذیر است.
پس از ورود، همین‌جا فرم را پر کنید تا تیم بلوط با شما هماهنگ کند.
</p>
<a href={loginUrl} className="btn-primary">
ورود به حساب کاربری
<ChevronLeft size={14} />
</a>
</div>
)}
<aside className="special-order-aside">
<div className="special-order-aside__badge">
<Sparkles size={18} strokeWidth={1.5} />
<span>درخواست سفارشی</span>
</div>
<p className="special-order-aside__lead">
ایدهٔ خاص دارید؟ جزئیات را بفرستید تا کیک یا شیرینی مدنظرتان را
برای شما آماده کنیم.
</p>
<ul className="special-order-aside__list">
{SIDE_NOTES.map((item) => (
<li key={item.title}>
<span className="star" aria-hidden>
✱
</span>
<div>
<strong>{item.title}</strong>
<p>{item.text}</p>
</div>
</li>
))}
</ul>
</aside>
</div>
</div>
<OffersHours showOffers={false} className="offers-hours--compact" />
</div>
)
}