diff --git a/CONTEXT.md b/CONTEXT.md index 259fbde..31ff381 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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/). -Repo: `https://git.meshkee.com/BaloutPastry/website.git` -API repo: `https://git.meshkee.com/BaloutPastry/backend.git` -Dashboards (customer login / account): `https://git.meshkee.com/BaloutPastry/dashboards.git` +Repo: `https://gitea.meshkee.com/balout-pastry/website.git` +API repo: `https://gitea.meshkee.com/balout-pastry/backend.git` +Dashboards (customer login / account): `https://gitea.meshkee.com/balout-pastry/dashboards.git` ## Stack @@ -25,7 +25,7 @@ Dashboards (customer login / account): `https://git.meshkee.com/BaloutPastry/das ## Setup on a new device ```bash -git clone https://git.meshkee.com/BaloutPastry/website.git +git clone https://gitea.meshkee.com/balout-pastry/website.git cd website cp .env.example .env @@ -151,5 +151,5 @@ Set `VITE_*` **before** `npm run build` (they are baked into the bundle). ## Related -- Backend setup: clone `BaloutPastry/backend` and read `CONTEXT.md` -- Customer / admin UI: clone `BaloutPastry/dashboards` and read `CONTEXT.md` +- Backend setup: clone `balout-pastry/backend` and read `CONTEXT.md` +- Customer / admin UI: clone `balout-pastry/dashboards` and read `CONTEXT.md` diff --git a/src/App.tsx b/src/App.tsx index 206219a..5059e84 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import ContactUsPage from './pages/ContactUsPage' import AboutUsPage from './pages/AboutUsPage' import ProductsListPage from './pages/ProductsListPage' import ProductsSlugOrDetailPage from './pages/ProductsSlugOrDetailPage' +import SpecialOrderPage from './pages/SpecialOrderPage' export default function App() { return ( @@ -21,6 +22,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0ceaeeb..5711e84 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -18,6 +18,7 @@ import './Header.css' const NAV_LINKS = [ { to: '/', label: 'صفحه اصلی' }, { to: '/products', label: 'محصولات', hasDropdown: true }, + { to: '/special-order', label: 'سفارش ویژه' }, { to: '/about-us', label: 'درباره ما' }, { to: '/contact-us', label: 'تماس با ما' }, { to: '/quick-info', label: 'اطلاعات مجموعه' }, diff --git a/src/lib/api.ts b/src/lib/api.ts index 20a023a..62bb382 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,9 @@ -import { getAccessToken } from './auth' +import { + clearSession, + getAccessToken, + getRefreshToken, + setSession, +} from './auth' import type { CategoryNode, Product, ProductListResponse } from './types' 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) { const text = await response.text().catch(() => '') try { @@ -76,7 +115,51 @@ async function parseError(response: Response) { return text || `Request failed (${response.status})` } -async function apiGet(path: string, auth = false): Promise { +let refreshInFlight: Promise | null = null + +async function tryRefreshSession(): Promise { + 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( + path: string, + auth = false, + skipRefresh = false, +): Promise { const headers: HeadersInit = {} if (auth) { const token = getAccessToken() @@ -84,6 +167,12 @@ async function apiGet(path: string, auth = false): Promise { headers.Authorization = `Bearer ${token}` } const response = await fetch(`${API_BASE}${path}`, { headers }) + + if (response.status === 401 && auth && !skipRefresh) { + const refreshed = await tryRefreshSession() + if (refreshed) return apiGet(path, auth, true) + } + if (!response.ok) throw new Error(await parseError(response)) return response.json() as Promise } @@ -92,6 +181,7 @@ async function apiSend( path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', body?: unknown, + skipRefresh = false, ): Promise { const token = getAccessToken() if (!token) throw new Error('لطفاً وارد حساب کاربری شوید') @@ -103,6 +193,12 @@ async function apiSend( }, body: body !== undefined ? JSON.stringify(body) : undefined, }) + + if (response.status === 401 && !skipRefresh) { + const refreshed = await tryRefreshSession() + if (refreshed) return apiSend(path, method, body, true) + } + if (!response.ok) throw new Error(await parseError(response)) if (response.status === 204) return undefined as T return response.json() as Promise @@ -170,3 +266,40 @@ export function createMyOrder(payload: CreateMyOrderPayload) { 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 +} + +export function createMySpecialOrder(payload: CreateMySpecialOrderPayload) { + return apiSend('/special-orders/mine', 'POST', payload) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index bf35006..dcf64b6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -105,6 +105,10 @@ export function getAccessToken(): string | null { return getSession()?.accessToken ?? null } +export function getRefreshToken(): string | null { + return getSession()?.refreshToken ?? null +} + export function isAuthenticated(): boolean { return getSession() !== null } diff --git a/src/pages/HomePage.css b/src/pages/HomePage.css index 9b5341c..79a2ecf 100644 --- a/src/pages/HomePage.css +++ b/src/pages/HomePage.css @@ -141,6 +141,49 @@ 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-section { margin-top: 200px; @@ -375,6 +418,24 @@ 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 { margin-top: 120px; } @@ -472,6 +533,22 @@ 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 { margin-top: 72px; } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index e851708..a6e1e36 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -225,6 +225,34 @@ export default function HomePage() { + {/* Special order promo */} +
+
+
+ +

+ کیک تولد با تم دلخواه، شیرینی مجلسی یا هر ایدهٔ خاصی که در ذهن دارید را برای ما + بنویسید. بدون پرداخت آنلاین، فقط درخواستتان را ثبت کنید تا تیم بلوط برای هماهنگی + با شما تماس بگیرد. +

+ + ثبت سفارش ویژه + + +
+
+ کیک و شیرینی سفارشی +
+
+
+ {renderCarousel(slotted[2])} {/* Sohan */} diff --git a/src/pages/SpecialOrderPage.css b/src/pages/SpecialOrderPage.css new file mode 100644 index 0000000..75e8d92 --- /dev/null +++ b/src/pages/SpecialOrderPage.css @@ -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)); + } +} diff --git a/src/pages/SpecialOrderPage.tsx b/src/pages/SpecialOrderPage.tsx new file mode 100644 index 0000000..30152e6 --- /dev/null +++ b/src/pages/SpecialOrderPage.tsx @@ -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((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(() => getAuthUser()) + const [category, setCategory] = useState('specialCake') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [gallery, setGallery] = useState([]) + 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) { + 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 ( +
+ + +
+
+ {authenticated ? ( +
void handleSubmit(e)} + > + {error ? ( +
+ {error} +
+ ) : null} + {success ? ( +
+ {success} +
+ ) : null} + +
+ دسته‌بندی +
+ {CATEGORIES.map((item) => ( + + ))} +
+
+ + + +