Add special order page and home entry points.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
458475abe6
commit
389fc6fc51
+6
-6
@@ -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`
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user