Initial commit of Balout Pastry public website.
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=http://localhost:3100/api/v1
|
||||
VITE_CUSTOMER_APP_URL=http://customer.baloutpastry.com:5173
|
||||
VITE_COOKIE_DOMAIN=.baloutpastry.com
|
||||
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# Balout Pastry — Website (setup context)
|
||||
|
||||
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`
|
||||
|
||||
## Stack
|
||||
|
||||
- React 19 + TypeScript + Vite 8
|
||||
- React Router 7
|
||||
- Lucide icons
|
||||
- Cart in `localStorage` (`balout.cart`)
|
||||
- Auth session cookie `balout.auth` (shared with customer dashboard on `*.baloutpastry.com`)
|
||||
- API client: `src/lib/api.ts` → `VITE_API_BASE_URL`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **20+** (LTS recommended)
|
||||
- npm
|
||||
- Running **Backend** API on port **3100**
|
||||
- Optional: customer dashboard on **5173** for login / account (SSO via cookie or hash handoff)
|
||||
|
||||
## Setup on a new device
|
||||
|
||||
```bash
|
||||
git clone https://git.meshkee.com/BaloutPastry/website.git
|
||||
cd website
|
||||
|
||||
cp .env.example .env
|
||||
# Edit if API / customer app URLs differ
|
||||
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **http://localhost:5174** (or **http://baloutpastry.com:5174** with local DNS).
|
||||
|
||||
### Local hosts (recommended)
|
||||
|
||||
Add to `/etc/hosts`:
|
||||
|
||||
```
|
||||
127.0.0.1 baloutpastry.com www.baloutpastry.com
|
||||
127.0.0.1 admin.baloutpastry.com customer.baloutpastry.com
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
| App | URL |
|
||||
|-----|-----|
|
||||
| Website | `http://baloutpastry.com:5174` |
|
||||
| Customer dashboard | `http://customer.baloutpastry.com:5173` |
|
||||
| Admin dashboard | `http://admin.baloutpastry.com:5173` |
|
||||
| API | `http://localhost:3100/api/v1` |
|
||||
|
||||
Backend `CORS_ORIGIN` must include the website origin(s), e.g.
|
||||
`http://baloutpastry.com:5174,http://www.baloutpastry.com:5174,http://localhost:5174`
|
||||
|
||||
### Backend must be up
|
||||
|
||||
From the backend repo:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run db:up
|
||||
npx prisma migrate deploy
|
||||
npm run start:dev # http://localhost:3100/api/v1
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What it does |
|
||||
|---------|----------------|
|
||||
| `npm run dev` | Vite dev server → **http://localhost:5174** |
|
||||
| `npm run build` | Typecheck + production build → `dist/` |
|
||||
| `npm run preview` | Serve production build locally |
|
||||
| `npm run lint` | Oxlint |
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `VITE_API_BASE_URL` | no | `http://localhost:3100/api/v1` | Must include `/api/v1` |
|
||||
| `VITE_CUSTOMER_APP_URL` | no | `http://customer.baloutpastry.com:5173` | Login / profile redirects |
|
||||
| `VITE_COOKIE_DOMAIN` | no | `.baloutpastry.com` (when on that host) | Shared auth cookie domain |
|
||||
|
||||
Copy from `.env.example`. Do **not** commit `.env`.
|
||||
|
||||
After changing env vars, restart `npm run dev`.
|
||||
|
||||
## App routes
|
||||
|
||||
| Path | Page |
|
||||
|------|------|
|
||||
| `/` | Home (hero, categories, featured products) |
|
||||
| `/products` | Product list + filters |
|
||||
| `/products/:slugOrId` | Category by slug **or** product detail by id |
|
||||
| `/quick-info` | Quick info |
|
||||
| `/about-us` | About |
|
||||
| `/contact-us` | Contact |
|
||||
|
||||
Cart drawer and toasts are global (not separate routes). Checkout / login hand off to the customer dashboard when needed.
|
||||
|
||||
## Auth / cart notes
|
||||
|
||||
- Guest browsing and cart work without login
|
||||
- Session cookie: `balout.auth` (also accepts `#balout_auth=…` hash handoff from customer app)
|
||||
- Access token sent as `Authorization: Bearer …` on authenticated API calls
|
||||
- Cart key: `balout.cart` in `localStorage`; change event `balout:cart-change`
|
||||
- Prices are integer **تومان**
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx Routes + shell (Header / Footer / Cart / Toast)
|
||||
pages/ Home, products, about, contact, quick-info
|
||||
components/ Header, Footer, CartDrawer, ProductCard, …
|
||||
lib/
|
||||
api.ts HTTP client + catalog / checkout helpers
|
||||
auth.ts Cookie session + customer-app redirects
|
||||
cart.ts localStorage cart
|
||||
types.ts Shared types + formatPriceFa
|
||||
ids.ts cuid vs category-slug detection
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set real API + customer app URLs
|
||||
npm ci
|
||||
npm run build
|
||||
# Serve dist/ behind nginx/Caddy, or:
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Set `VITE_*` **before** `npm run build` (they are baked into the bundle).
|
||||
|
||||
## Common issues
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---------|----------------|
|
||||
| Empty catalog / fetch errors | Backend not running, or wrong `VITE_API_BASE_URL` |
|
||||
| CORS errors in browser | Backend `CORS_ORIGIN` missing website origin (port **5174**) |
|
||||
| Port already in use | Another process on **5174** (`strictPort: true`) |
|
||||
| Login redirect fails | Customer dashboard down, or wrong `VITE_CUSTOMER_APP_URL` |
|
||||
| Auth not shared across apps | Missing `/etc/hosts` + `VITE_COOKIE_DOMAIN=.baloutpastry.com` |
|
||||
|
||||
## Related
|
||||
|
||||
- Backend setup: clone `BaloutPastry/backend` and read `CONTEXT.md`
|
||||
- Customer / admin UI: clone `BaloutPastry/dashboards` and read `CONTEXT.md`
|
||||
@@ -0,0 +1,11 @@
|
||||
# Balout Website
|
||||
|
||||
Public storefront for شیرینی بلوط — design matched to [shirinibalout.com](https://www.shirinibalout.com/).
|
||||
|
||||
```bash
|
||||
cd Website
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Opens at http://localhost:5173
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>شیرینی بلوط - صفحه اصلی</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "balout-website",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,33 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import Header from './components/Header'
|
||||
import Footer from './components/Footer'
|
||||
import ToastHost from './components/ToastHost'
|
||||
import CartDrawer from './components/CartDrawer'
|
||||
import HomePage from './pages/HomePage'
|
||||
import QuickInfoPage from './pages/QuickInfoPage'
|
||||
import ContactUsPage from './pages/ContactUsPage'
|
||||
import AboutUsPage from './pages/AboutUsPage'
|
||||
import ProductsListPage from './pages/ProductsListPage'
|
||||
import ProductsSlugOrDetailPage from './pages/ProductsSlugOrDetailPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<Header />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/quick-info" element={<QuickInfoPage />} />
|
||||
<Route path="/contact-us" element={<ContactUsPage />} />
|
||||
<Route path="/about-us" element={<AboutUsPage />} />
|
||||
<Route path="/products" element={<ProductsListPage />} />
|
||||
<Route path="/products/:slugOrId" element={<ProductsSlugOrDetailPage />} />
|
||||
<Route path="*" element={<HomePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
<ToastHost />
|
||||
<CartDrawer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_88_1881)">
|
||||
<g clip-path="url(#clip1_88_1881)">
|
||||
<path d="M12.4185 20.9426L37.487 34.6632L62.5555 20.9426" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M37.4873 34.6659V61.7283" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M61.6454 48.8049L38.4972 61.4785C38.1874 61.648 37.8401 61.7368 37.487 61.7368C37.134 61.7368 36.7866 61.648 36.4769 61.4785L13.3287 48.8049C12.9981 48.624 12.7222 48.3577 12.5297 48.0338C12.3372 47.7099 12.2352 47.3403 12.2344 46.9635V21.8003C12.2352 21.4235 12.3372 21.0539 12.5297 20.73C12.7222 20.4061 12.9981 20.1398 13.3287 19.9589L36.4769 7.28525C36.7866 7.11579 37.134 7.02698 37.487 7.02698C37.8401 7.02698 38.1874 7.11579 38.4972 7.28525L61.6454 19.9589C61.976 20.1398 62.2519 20.4061 62.4444 20.73C62.6369 21.0539 62.7389 21.4235 62.7397 21.8003V46.9583C62.7398 47.3359 62.6383 47.7067 62.4457 48.0316C62.2532 48.3565 61.9768 48.6236 61.6454 48.8049Z" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M25.271 13.4169L50.1133 27.0139V40.6924" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_88_1881">
|
||||
<rect width="74.291" height="74.291" fill="white" transform="translate(0.708984 0.708984)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_88_1881">
|
||||
<rect width="67.3405" height="67.3405" fill="white" transform="translate(3.81689 0.708984)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="112" height="112" viewBox="0 0 112 112" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_38_93)">
|
||||
<path d="M94.6052 68.3315V87.0194C94.6052 88.8594 93.8743 90.624 92.5733 91.925C91.2722 93.226 89.5077 93.9569 87.6677 93.9569H25.2302C23.3903 93.9569 21.6257 93.226 20.3247 91.925C19.0236 90.624 18.2927 88.8594 18.2927 87.0194V68.3315" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M56.449 31.5193V41.9255" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.7615 41.9255H91.1365C92.9764 41.9255 94.741 42.6565 96.042 43.9575C97.3431 45.2585 98.074 47.0231 98.074 48.863V58.9788C98.074 66.5407 92.1467 72.9318 84.5892 73.14C82.7349 73.1921 80.889 72.8719 79.1606 72.1983C77.4322 71.5247 75.8564 70.5113 74.5264 69.2182C73.1964 67.925 72.1393 66.3782 71.4175 64.6694C70.6956 62.9605 70.3238 61.1243 70.324 59.2693C70.324 62.9492 68.8621 66.4783 66.2601 69.0804C63.658 71.6825 60.1289 73.1443 56.449 73.1443C52.7691 73.1443 49.2399 71.6825 46.6379 69.0804C44.0358 66.4783 42.574 62.9492 42.574 59.2693C42.5747 61.1247 42.2033 62.9614 41.4817 64.6707C40.7602 66.3801 39.7031 67.9273 38.3731 69.221C37.0431 70.5146 35.4671 71.5284 33.7384 72.2023C32.0097 72.8761 30.1634 73.1965 28.3087 73.1443C20.7512 72.9318 14.824 66.5407 14.824 58.9788V48.863C14.824 47.0231 15.5549 45.2585 16.8559 43.9575C18.157 42.6565 19.9215 41.9255 21.7615 41.9255Z" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M66.8552 21.113C66.8552 23.8729 65.7589 26.5198 63.8073 28.4714C61.8558 30.4229 59.2089 31.5193 56.449 31.5193C53.6891 31.5193 51.0422 30.4229 49.0906 28.4714C47.1391 26.5198 46.0427 23.8729 46.0427 21.113C46.0427 10.7068 56.449 3.76929 56.449 3.76929C56.449 3.76929 66.8552 10.7068 66.8552 21.113Z" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_38_93">
|
||||
<rect width="111" height="111" fill="white" transform="translate(0.948975 0.300537)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="109" height="108" viewBox="0 0 109 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_88_1820)">
|
||||
<path d="M56.2666 57.375C58.1306 57.375 59.6416 55.864 59.6416 54C59.6416 52.136 58.1306 50.625 56.2666 50.625C54.4026 50.625 52.8916 52.136 52.8916 54C52.8916 55.864 54.4026 57.375 56.2666 57.375Z" fill="#4B4B4B"/>
|
||||
<path d="M37.7041 57.375C39.5681 57.375 41.0791 55.864 41.0791 54C41.0791 52.136 39.5681 50.625 37.7041 50.625C35.8401 50.625 34.3291 52.136 34.3291 54C34.3291 55.864 35.8401 57.375 37.7041 57.375Z" fill="#4B4B4B"/>
|
||||
<path d="M74.8291 57.375C76.6931 57.375 78.2041 55.864 78.2041 54C78.2041 52.136 76.6931 50.625 74.8291 50.625C72.9651 50.625 71.4541 52.136 71.4541 54C71.4541 55.864 72.9651 57.375 74.8291 57.375Z" fill="#4B4B4B"/>
|
||||
<path d="M56.2666 91.125H20.8291C19.934 91.125 19.0756 90.7694 18.4426 90.1365C17.8097 89.5036 17.4541 88.6451 17.4541 87.75V52.3125C17.4541 42.0188 21.5433 32.1467 28.822 24.8679C36.1008 17.5892 45.9729 13.5 56.2666 13.5C61.3635 13.5 66.4106 14.5039 71.1195 16.4544C75.8285 18.4049 80.1071 21.2638 83.7112 24.8679C87.3153 28.472 90.1742 32.7506 92.1247 37.4596C94.0752 42.1685 95.0791 47.2156 95.0791 52.3125C95.0791 57.4094 94.0752 62.4565 92.1247 67.1654C90.1742 71.8744 87.3153 76.153 83.7112 79.7571C80.1071 83.3612 75.8285 86.2201 71.1195 88.1706C66.4106 90.1211 61.3635 91.125 56.2666 91.125Z" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_88_1820">
|
||||
<rect width="108" height="108" fill="white" transform="translate(0.579102)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 673 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="112" height="111" viewBox="0 0 112 111" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_86_370)">
|
||||
<path d="M91.5572 31.2188H20.734C19.8814 31.2186 19.0581 31.5302 18.4193 32.0948C17.7804 32.6594 17.37 33.4381 17.2652 34.2843L11.0865 86.3155C11.0294 86.8037 11.0766 87.2985 11.2252 87.7671C11.3737 88.2356 11.6201 88.6673 11.9481 89.0334C12.2761 89.3995 12.6782 89.6917 13.1276 89.8907C13.5771 90.0897 14.0637 90.1908 14.5553 90.1875H97.7359C98.2274 90.1908 98.714 90.0897 99.1635 89.8907C99.613 89.6917 100.015 89.3995 100.343 89.0334C100.671 88.6673 100.917 88.2356 101.066 87.7671C101.215 87.2985 101.262 86.8037 101.205 86.3155L95.0259 34.2843C94.9212 33.4381 94.5108 32.6594 93.8719 32.0948C93.233 31.5302 92.4098 31.2186 91.5572 31.2188Z" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M38.8018 45.0938V27.75C38.8018 23.1502 40.629 18.7387 43.8816 15.4861C47.1342 12.2335 51.5457 10.4063 56.1455 10.4062C60.7454 10.4062 65.1568 12.2335 68.4094 15.4861C71.662 18.7387 73.4893 23.1502 73.4893 27.75V45.0938" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_86_370">
|
||||
<rect width="111" height="111" fill="white" transform="translate(0.645508)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="113" height="113" viewBox="0 0 113 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_88_1808)">
|
||||
<path d="M52.9688 52.9688C53.9053 52.9688 54.8035 53.3408 55.4657 54.003C56.128 54.6653 56.5 55.5635 56.5 56.5V74.1562C56.5 75.0928 56.872 75.991 57.5343 76.6532C58.1965 77.3155 59.0947 77.6875 60.0312 77.6875" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M54.7344 40.6094C56.6846 40.6094 58.2656 39.0284 58.2656 37.0781C58.2656 35.1279 56.6846 33.5469 54.7344 33.5469C52.7841 33.5469 51.2031 35.1279 51.2031 37.0781C51.2031 39.0284 52.7841 40.6094 54.7344 40.6094Z" fill="#4B4B4B"/>
|
||||
<path d="M56.5 98.875C79.9031 98.875 98.875 79.9031 98.875 56.5C98.875 33.0969 79.9031 14.125 56.5 14.125C33.0969 14.125 14.125 33.0969 14.125 56.5C14.125 79.9031 33.0969 98.875 56.5 98.875Z" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_88_1808">
|
||||
<rect width="113" height="113" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="116" height="116" viewBox="0 0 116 116" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_58_103)">
|
||||
<path d="M84.1046 97.875C57.0576 65.25 109.693 50.75 82.6456 18.125" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M58.7296 97.875C31.6826 65.25 84.3176 50.75 57.2706 18.125" stroke="#4B4B4B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M33.3546 97.875C6.30759 65.25 58.9426 50.75 31.8956 18.125" stroke="#8F410C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_58_103">
|
||||
<rect width="116" height="116" fill="white" transform="translate(0 116) rotate(-90)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 354 KiB |
@@ -0,0 +1,568 @@
|
||||
.cart-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
background: rgba(47, 24, 10, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.cart-drawer {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: min(440px, 100%);
|
||||
background: #fffaf5;
|
||||
box-shadow: 20px 0 50px rgba(79, 35, 8, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: cart-slide 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes cart-slide {
|
||||
from {
|
||||
transform: translateX(-16px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-drawer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 22px 22px 12px;
|
||||
}
|
||||
|
||||
.cart-drawer__eyebrow {
|
||||
margin: 0;
|
||||
color: rgba(143, 65, 12, 0.55);
|
||||
font-family: Cailyne, Georgia, serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cart-drawer__title {
|
||||
margin: 4px 0 0;
|
||||
color: var(--color-primary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.cart-drawer__close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
color: var(--color-primary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.cart-drawer__steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 0 22px 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-gray-300);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cart-drawer__step span {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(143, 65, 12, 0.2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-drawer__step em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cart-drawer__step.is-active,
|
||||
.cart-drawer__step.is-done {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.cart-drawer__step.is-active span,
|
||||
.cart-drawer__step.is-done span {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.cart-drawer__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 22px 16px;
|
||||
}
|
||||
|
||||
.cart-drawer__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cart-drawer__error {
|
||||
background: #fde8e8;
|
||||
color: #9b2c2c;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cart-drawer__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--color-secondary);
|
||||
padding: 48px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-drawer__muted {
|
||||
color: var(--color-gray-300);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cart-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cart-line {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.cart-line__image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cart-line__name {
|
||||
color: var(--color-gray-400);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-line__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-line__calc-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cart-line__calc-row--option {
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
|
||||
.cart-line__option-name {
|
||||
width: 100%;
|
||||
color: var(--color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cart-line__calc {
|
||||
color: var(--color-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cart-line__result {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cart-line__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cart-line__line-total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
color: var(--color-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cart-line__line-total strong {
|
||||
color: var(--color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cart-line__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cart-line__actions strong {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cart-line__remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9b2c2c;
|
||||
padding: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qty-stepper--sm {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.qty-stepper--sm .qty-stepper__btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.qty-stepper__value {
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.cart-delivery-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-delivery-toggle button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.15);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 8px;
|
||||
font-family: inherit;
|
||||
color: var(--color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cart-delivery-toggle button.is-active {
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-choice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-choice {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cart-choice.is-active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(143, 65, 12, 0.05);
|
||||
}
|
||||
|
||||
.cart-choice input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.cart-choice strong {
|
||||
display: block;
|
||||
color: var(--color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cart-choice em {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-style: normal;
|
||||
color: var(--color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cart-drawer__link-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: start;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cart-address-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-address-form input,
|
||||
.cart-address-form select,
|
||||
.cart-address-form textarea,
|
||||
.cart-field input,
|
||||
.cart-field textarea {
|
||||
border: 1px solid rgba(143, 65, 12, 0.15);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cart-pay-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(143, 65, 12, 0.05);
|
||||
}
|
||||
|
||||
.cart-pay-summary > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cart-pay-summary__total {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.12);
|
||||
color: var(--color-primary) !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.cart-discount-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cart-discount-input {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cart-discount-row button {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(143, 65, 12, 0.1);
|
||||
color: var(--color-primary);
|
||||
padding: 0 14px;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-discount-row button:disabled,
|
||||
.cart-discount-input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cart-discount-active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(34, 140, 78, 0.12);
|
||||
border: 1px solid rgba(34, 140, 78, 0.28);
|
||||
color: #1b6b3a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cart-discount-active__cancel {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(27, 107, 58, 0.12);
|
||||
color: #1b6b3a;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cart-discount-active__cancel:hover {
|
||||
background: rgba(27, 107, 58, 0.22);
|
||||
}
|
||||
|
||||
.cart-pay-method {
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed rgba(143, 65, 12, 0.25);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.cart-pay-method strong {
|
||||
display: block;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cart-pay-method p,
|
||||
.cart-pay-method em {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cart-pay-method em {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
.cart-drawer__footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 22px 22px;
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.cart-drawer__ghost {
|
||||
border: 1px solid rgba(143, 65, 12, 0.15);
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cart-drawer__primary {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cart-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cart-drawer__header,
|
||||
.cart-drawer__steps,
|
||||
.cart-drawer__body {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.cart-drawer__header {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.cart-drawer__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cart-drawer__step em {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cart-line {
|
||||
grid-template-columns: 64px 1fr;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cart-line__image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.cart-line__footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cart-drawer__footer {
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.cart-drawer__ghost {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cart-drawer__primary {
|
||||
flex: 1 1 100%;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.cart-discount-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cart-discount-row button {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ChevronLeft,
|
||||
MapPin,
|
||||
Minus,
|
||||
Plus,
|
||||
ShoppingBag,
|
||||
Store,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
createMyAddress,
|
||||
createMyOrder,
|
||||
listBranches,
|
||||
listDistricts,
|
||||
listMyAddresses,
|
||||
listMyDiscounts,
|
||||
listShipping,
|
||||
type Branch,
|
||||
type Discount,
|
||||
type ShippingException,
|
||||
type UserAddress,
|
||||
} from '../lib/api'
|
||||
import {
|
||||
CART_OPEN_EVENT,
|
||||
clearCart,
|
||||
getCartCheckoutReturnUrl,
|
||||
getCartItems,
|
||||
getCartSubtotal,
|
||||
openCartDrawer,
|
||||
removeCartItem,
|
||||
setCartItemQuantity,
|
||||
showToast,
|
||||
subscribeCart,
|
||||
type CartItem,
|
||||
type CartOpenDetail,
|
||||
} from '../lib/cart'
|
||||
import {
|
||||
consumeAuthHashFromUrl,
|
||||
getAuthUser,
|
||||
getCustomerLoginUrl,
|
||||
isAuthenticated,
|
||||
} from '../lib/auth'
|
||||
import { formatPriceFa } from '../lib/types'
|
||||
import defaultPhoto from '../assets/images/DefaultPhoto.png'
|
||||
import './CartDrawer.css'
|
||||
|
||||
type Step = 1 | 2 | 3
|
||||
type DeliveryType = 'pickup' | 'shipping'
|
||||
|
||||
function applyDiscount(subtotal: number, discount: Discount | null) {
|
||||
if (!discount) return 0
|
||||
if (subtotal < discount.minOrderAmount) return 0
|
||||
const raw = Math.round((subtotal * discount.percent) / 100)
|
||||
return Math.min(raw, discount.maxValue)
|
||||
}
|
||||
|
||||
export default function CartDrawer() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<Step>(1)
|
||||
const [items, setItems] = useState<CartItem[]>(() => getCartItems())
|
||||
const [deliveryType, setDeliveryType] = useState<DeliveryType>('pickup')
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [addresses, setAddresses] = useState<UserAddress[]>([])
|
||||
const [districts, setDistricts] = useState<string[]>([])
|
||||
const [shipping, setShipping] = useState<ShippingException[]>([])
|
||||
const [discounts, setDiscounts] = useState<Discount[]>([])
|
||||
const [branchId, setBranchId] = useState('')
|
||||
const [addressId, setAddressId] = useState('')
|
||||
const [discountCode, setDiscountCode] = useState('')
|
||||
const [appliedDiscount, setAppliedDiscount] = useState<Discount | null>(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [loadingMeta, setLoadingMeta] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showNewAddress, setShowNewAddress] = useState(false)
|
||||
const [newAddress, setNewAddress] = useState({
|
||||
name: '',
|
||||
district: '',
|
||||
address: '',
|
||||
landline: '',
|
||||
})
|
||||
|
||||
const subtotal = useMemo(() => getCartSubtotal(), [items])
|
||||
const selectedAddress = addresses.find((row) => row.id === addressId)
|
||||
const shippingFee =
|
||||
deliveryType === 'shipping' && selectedAddress
|
||||
? shipping.find((row) => row.district === selectedAddress.district)?.price ?? 0
|
||||
: 0
|
||||
const discountValue = applyDiscount(subtotal, appliedDiscount)
|
||||
const payable = Math.max(0, subtotal + shippingFee - discountValue)
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeCart(() => setItems(getCartItems()))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
consumeAuthHashFromUrl()
|
||||
|
||||
function onOpen(event: Event) {
|
||||
const detail = (event as CustomEvent<CartOpenDetail>).detail
|
||||
const nextStep = detail?.step ?? 1
|
||||
setItems(getCartItems())
|
||||
setError('')
|
||||
// Guests may only stay on the items step.
|
||||
if (nextStep > 1 && !isAuthenticated()) {
|
||||
setStep(1)
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
setStep(nextStep)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener(CART_OPEN_EVENT, onOpen)
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const cartParam = params.get('cart')
|
||||
if (cartParam === 'checkout' || cartParam === '1' || cartParam === 'open') {
|
||||
if (cartParam === 'checkout' && isAuthenticated()) {
|
||||
openCartDrawer(2)
|
||||
} else {
|
||||
openCartDrawer(1)
|
||||
}
|
||||
params.delete('cart')
|
||||
const clean = `${window.location.pathname}${params.toString() ? `?${params}` : ''}${window.location.hash}`
|
||||
window.history.replaceState(null, '', clean)
|
||||
}
|
||||
|
||||
return () => window.removeEventListener(CART_OPEN_EVENT, onOpen)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || step === 1) return
|
||||
if (!isAuthenticated()) {
|
||||
setStep(1)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoadingMeta(true)
|
||||
setError('')
|
||||
Promise.all([
|
||||
listBranches(),
|
||||
listMyAddresses(),
|
||||
listDistricts(),
|
||||
listShipping(),
|
||||
listMyDiscounts(),
|
||||
])
|
||||
.then(([branchRows, addressRows, districtRows, shippingRows, discountRows]) => {
|
||||
if (cancelled) return
|
||||
setBranches(branchRows)
|
||||
setAddresses(addressRows)
|
||||
setDistricts(districtRows)
|
||||
setShipping(shippingRows)
|
||||
setDiscounts(discountRows.items.filter((d) => d.active && !d.expired))
|
||||
if (!branchId && branchRows[0]) setBranchId(branchRows[0].id)
|
||||
if (!addressId && addressRows[0]) setAddressId(addressRows[0].id)
|
||||
if (!newAddress.district && districtRows[0]) {
|
||||
setNewAddress((current) => ({ ...current, district: districtRows[0] }))
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message || 'خطا در دریافت اطلاعات')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingMeta(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, step])
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
setError('')
|
||||
if (step === 1) {
|
||||
if (items.length === 0) {
|
||||
setError('سبد خرید خالی است')
|
||||
return
|
||||
}
|
||||
// Auth is required only after reviewing cart items.
|
||||
if (!isAuthenticated()) {
|
||||
window.location.assign(getCustomerLoginUrl(getCartCheckoutReturnUrl()))
|
||||
return
|
||||
}
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
if (step === 2) {
|
||||
if (deliveryType === 'pickup' && !branchId) {
|
||||
setError('شعبه را انتخاب کنید')
|
||||
return
|
||||
}
|
||||
if (deliveryType === 'shipping' && !addressId) {
|
||||
setError('آدرس را انتخاب کنید')
|
||||
return
|
||||
}
|
||||
setStep(3)
|
||||
}
|
||||
}
|
||||
|
||||
function applyCode() {
|
||||
const code = discountCode.trim()
|
||||
if (!code) {
|
||||
setAppliedDiscount(null)
|
||||
return
|
||||
}
|
||||
const found = discounts.find(
|
||||
(row) => row.code.toLowerCase() === code.toLowerCase(),
|
||||
)
|
||||
if (!found) {
|
||||
setError('کد تخفیف معتبر نیست')
|
||||
setAppliedDiscount(null)
|
||||
return
|
||||
}
|
||||
if (subtotal < found.minOrderAmount) {
|
||||
setError(
|
||||
`حداقل مبلغ سفارش برای این کد ${formatPriceFa(found.minOrderAmount)} تومان است`,
|
||||
)
|
||||
setAppliedDiscount(null)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setAppliedDiscount(found)
|
||||
showToast({ message: 'کد تخفیف اعمال شد' })
|
||||
}
|
||||
|
||||
async function saveAddress() {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
const created = await createMyAddress(newAddress)
|
||||
setAddresses((current) => [created, ...current])
|
||||
setAddressId(created.id)
|
||||
setShowNewAddress(false)
|
||||
setNewAddress({ name: '', district: districts[0] || '', address: '', landline: '' })
|
||||
showToast({ message: 'آدرس جدید ذخیره شد' })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ثبت آدرس ناموفق بود')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
const order = await createMyOrder({
|
||||
deliveryType,
|
||||
branchId: deliveryType === 'pickup' ? branchId : undefined,
|
||||
shippingAddressId: deliveryType === 'shipping' ? addressId : undefined,
|
||||
discountCode: appliedDiscount?.code,
|
||||
note: note.trim() || undefined,
|
||||
items: items.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
optionIds: item.options.map((option) => option.id),
|
||||
})),
|
||||
})
|
||||
clearCart()
|
||||
setItems([])
|
||||
setStep(1)
|
||||
setOpen(false)
|
||||
showToast({
|
||||
message: 'سفارش با موفقیت ثبت شد',
|
||||
detail: `کد سفارش ${order.code} — مبلغ ${formatPriceFa(order.totalPrice)} تومان`,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'ثبت سفارش ناموفق بود')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const user = getAuthUser()
|
||||
|
||||
return (
|
||||
<div className="cart-drawer-overlay" onClick={close} role="presentation">
|
||||
<aside
|
||||
className="cart-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="سبد خرید"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="cart-drawer__header">
|
||||
<div>
|
||||
<p className="cart-drawer__eyebrow">Shopping Cart</p>
|
||||
<h2 className="cart-drawer__title">سبد خرید</h2>
|
||||
</div>
|
||||
<button type="button" className="cart-drawer__close" onClick={close} aria-label="بستن">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer__steps">
|
||||
{[
|
||||
{ n: 1 as Step, label: 'اقلام' },
|
||||
{ n: 2 as Step, label: 'ارسال' },
|
||||
{ n: 3 as Step, label: 'پرداخت' },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.n}
|
||||
className={`cart-drawer__step${step === row.n ? ' is-active' : ''}${step > row.n ? ' is-done' : ''}`}
|
||||
>
|
||||
<span>{row.n.toLocaleString('fa-IR')}</span>
|
||||
<em>{row.label}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer__body">
|
||||
{error && <div className="cart-drawer__error">{error}</div>}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="cart-drawer__section">
|
||||
{items.length === 0 ? (
|
||||
<div className="cart-drawer__empty">
|
||||
<ShoppingBag size={36} strokeWidth={1.4} />
|
||||
<p>سبد خرید شما خالی است</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="cart-lines">
|
||||
{items.map((item) => {
|
||||
const unitLabel = item.sellUnit === 'kilo' ? 'کیلو' : 'واحد'
|
||||
const productTotal = Math.round(item.quantity * item.basePrice)
|
||||
const optionRows = item.options.map((option) => ({
|
||||
...option,
|
||||
total: Math.round(item.quantity * option.price),
|
||||
}))
|
||||
const lineTotal =
|
||||
productTotal +
|
||||
optionRows.reduce((sum, option) => sum + option.total, 0)
|
||||
const step = item.sellUnit === 'kilo' ? 0.1 : 1
|
||||
|
||||
return (
|
||||
<li key={item.key} className="cart-line">
|
||||
<img
|
||||
src={item.imageUrl || defaultPhoto}
|
||||
alt=""
|
||||
className="cart-line__image"
|
||||
/>
|
||||
<div className="cart-line__info">
|
||||
<div className="cart-line__top">
|
||||
<div className="cart-line__name">{item.nameFa}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cart-line__remove"
|
||||
onClick={() => removeCartItem(item.key)}
|
||||
aria-label="حذف"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cart-line__calc-row">
|
||||
<span className="cart-line__calc" dir="rtl">
|
||||
{formatPriceFa(item.quantity)}{' '}
|
||||
<bdi>{unitLabel}</bdi>
|
||||
{' × '}
|
||||
{formatPriceFa(item.basePrice)}
|
||||
</span>
|
||||
<span className="cart-line__result" dir="rtl">
|
||||
= {formatPriceFa(productTotal)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{optionRows.map((option) => (
|
||||
<div key={option.id} className="cart-line__calc-row cart-line__calc-row--option">
|
||||
<span className="cart-line__option-name">{option.name}</span>
|
||||
<span className="cart-line__calc" dir="rtl">
|
||||
{formatPriceFa(item.quantity)}{' '}
|
||||
<bdi>{unitLabel}</bdi>
|
||||
{' × '}
|
||||
{formatPriceFa(option.price)}
|
||||
</span>
|
||||
<span className="cart-line__result" dir="rtl">
|
||||
= {formatPriceFa(option.total)} تومان
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="cart-line__footer">
|
||||
<div className="qty-stepper qty-stepper--sm" dir="ltr">
|
||||
<button
|
||||
type="button"
|
||||
className="qty-stepper__btn"
|
||||
onClick={() =>
|
||||
setCartItemQuantity(item.key, item.quantity - step)
|
||||
}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="qty-stepper__value">
|
||||
{formatPriceFa(item.quantity)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="qty-stepper__btn"
|
||||
onClick={() =>
|
||||
setCartItemQuantity(item.key, item.quantity + step)
|
||||
}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="cart-line__line-total">
|
||||
<span>جمع این قلم</span>
|
||||
<strong>{formatPriceFa(lineTotal)} تومان</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="cart-drawer__section">
|
||||
{loadingMeta ? (
|
||||
<p className="cart-drawer__muted">در حال بارگذاری...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="cart-delivery-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={deliveryType === 'pickup' ? 'is-active' : undefined}
|
||||
onClick={() => setDeliveryType('pickup')}
|
||||
>
|
||||
<Store size={16} />
|
||||
تحویل حضوری
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={deliveryType === 'shipping' ? 'is-active' : undefined}
|
||||
onClick={() => setDeliveryType('shipping')}
|
||||
>
|
||||
<MapPin size={16} />
|
||||
ارسال به آدرس
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{deliveryType === 'pickup' ? (
|
||||
<div className="cart-choice-list">
|
||||
{branches.map((branch) => (
|
||||
<label key={branch.id} className={`cart-choice${branchId === branch.id ? ' is-active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="branch"
|
||||
checked={branchId === branch.id}
|
||||
onChange={() => setBranchId(branch.id)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{branch.name}</strong>
|
||||
<em>
|
||||
{branch.district} — {branch.address}
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{branches.length === 0 && (
|
||||
<p className="cart-drawer__muted">شعبهای ثبت نشده است</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="cart-choice-list">
|
||||
{addresses.map((address) => (
|
||||
<label
|
||||
key={address.id}
|
||||
className={`cart-choice${addressId === address.id ? ' is-active' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="address"
|
||||
checked={addressId === address.id}
|
||||
onChange={() => setAddressId(address.id)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{address.name}</strong>
|
||||
<em>
|
||||
{address.district} — {address.address}
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cart-drawer__link-btn"
|
||||
onClick={() => setShowNewAddress((v) => !v)}
|
||||
>
|
||||
{showNewAddress ? 'بستن فرم آدرس' : 'افزودن آدرس جدید'}
|
||||
</button>
|
||||
{showNewAddress && (
|
||||
<div className="cart-address-form">
|
||||
<input
|
||||
placeholder="عنوان (منزل / محل کار)"
|
||||
value={newAddress.name}
|
||||
onChange={(e) =>
|
||||
setNewAddress((c) => ({ ...c, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<select
|
||||
value={newAddress.district}
|
||||
onChange={(e) =>
|
||||
setNewAddress((c) => ({ ...c, district: e.target.value }))
|
||||
}
|
||||
>
|
||||
{districts.map((district) => (
|
||||
<option key={district} value={district}>
|
||||
{district}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
placeholder="آدرس کامل"
|
||||
rows={3}
|
||||
value={newAddress.address}
|
||||
onChange={(e) =>
|
||||
setNewAddress((c) => ({ ...c, address: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
placeholder="تلفن ثابت"
|
||||
value={newAddress.landline}
|
||||
onChange={(e) =>
|
||||
setNewAddress((c) => ({ ...c, landline: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
onClick={saveAddress}
|
||||
>
|
||||
ذخیره آدرس
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="cart-drawer__section">
|
||||
<div className="cart-pay-summary">
|
||||
<div>
|
||||
<span>جمع اقلام</span>
|
||||
<strong>{formatPriceFa(subtotal)} تومان</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>هزینه ارسال</span>
|
||||
<strong>
|
||||
{shippingFee > 0 ? `${formatPriceFa(shippingFee)} تومان` : '۰ تومان'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>تخفیف</span>
|
||||
<strong>
|
||||
{discountValue > 0
|
||||
? `${formatPriceFa(discountValue)}- تومان`
|
||||
: '۰ تومان'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="cart-pay-summary__total">
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<strong>{formatPriceFa(payable)} تومان</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="cart-field">
|
||||
<span>کد تخفیف</span>
|
||||
<div className="cart-discount-row">
|
||||
<input
|
||||
value={discountCode}
|
||||
onChange={(e) =>
|
||||
setDiscountCode(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder="مثلاً WELCOME10"
|
||||
dir="ltr"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
className="cart-discount-input"
|
||||
disabled={Boolean(appliedDiscount)}
|
||||
/>
|
||||
<button type="button" onClick={applyCode} disabled={Boolean(appliedDiscount)}>
|
||||
اعمال
|
||||
</button>
|
||||
</div>
|
||||
{appliedDiscount && (
|
||||
<div className="cart-discount-active" role="status">
|
||||
<span>
|
||||
{appliedDiscount.code} —{' '}
|
||||
{appliedDiscount.percent.toLocaleString('fa-IR')}٪
|
||||
{discountValue > 0
|
||||
? ` (${formatPriceFa(discountValue)} تومان تخفیف)`
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cart-discount-active__cancel"
|
||||
aria-label="لغو تخفیف"
|
||||
onClick={() => {
|
||||
setAppliedDiscount(null)
|
||||
setDiscountCode('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="cart-field">
|
||||
<span>یادداشت سفارش</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="توضیح اختیاری"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="cart-pay-method">
|
||||
<strong>روش پرداخت</strong>
|
||||
<p>پرداخت در محل / هنگام تحویل</p>
|
||||
{user && <em>{user.name} — {user.cellNumber}</em>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cart-drawer__footer">
|
||||
{step > 1 && (
|
||||
<button type="button" className="cart-drawer__ghost" onClick={() => setStep((s) => (s - 1) as Step)}>
|
||||
قبلی
|
||||
</button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<button type="button" className="btn-primary cart-drawer__primary" onClick={goNext}>
|
||||
ادامه
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary cart-drawer__primary"
|
||||
disabled={submitting || items.length === 0}
|
||||
onClick={submitOrder}
|
||||
>
|
||||
{submitting ? 'در حال ثبت...' : 'پرداخت و ثبت سفارش'}
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
.site-footer {
|
||||
margin-top: 50px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.site-footer__divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.15);
|
||||
margin: 0 0 40px;
|
||||
}
|
||||
|
||||
.site-footer__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
max-width: 1100px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.site-footer__about img {
|
||||
width: 65px;
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.site-footer__about p {
|
||||
color: var(--color-gray-400);
|
||||
font-size: clamp(12px, 3vw, 14px);
|
||||
line-height: 30px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.site-footer__branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.site-footer__branch-title {
|
||||
color: var(--color-gray-400);
|
||||
font-size: clamp(15px, 3vw, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-footer__label {
|
||||
color: var(--color-gray-400);
|
||||
font-size: clamp(12px, 3vw, 15px);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.site-footer__value {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(14px, 3vw, 18px);
|
||||
}
|
||||
|
||||
.site-footer__bottom {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-footer__social {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
.site-footer__social a {
|
||||
color: var(--color-gray-300);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-footer__social a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.site-footer__copy {
|
||||
margin-top: 12px;
|
||||
color: var(--color-gray-300);
|
||||
font-size: clamp(12px, 3vw, 15px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-footer {
|
||||
margin-top: 32px;
|
||||
padding-bottom: calc(28px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.site-footer__divider {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.site-footer__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-footer__about img {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.site-footer__about p {
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.site-footer__branch {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Instagram, Send } from 'lucide-react'
|
||||
import logo from '../assets/images/logo.png'
|
||||
import './Footer.css'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="max-container">
|
||||
<hr className="site-footer__divider" />
|
||||
<div className="site-footer__grid">
|
||||
<div className="site-footer__about">
|
||||
<img src={logo} alt="لوگو" width={65} />
|
||||
<p>
|
||||
شیرینیسرای بلوط از سال ۱۳۷۸ در قم، ارائهدهنده انواع کیک، شیرینی و سوهان
|
||||
باکیفیت و تازه با امکان سفارشیسازی و ارسال در سراسر قم.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="site-footer__branch">
|
||||
<p className="site-footer__branch-title">اطلاعات تماس شعبه یکم</p>
|
||||
<div>
|
||||
<p className="site-footer__label">آدرس ما</p>
|
||||
<p className="site-footer__value">میدان جهاد،باجک اول،نبش کوی ۴۴</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="site-footer__label">شماره تماس</p>
|
||||
<p className="site-footer__value">۳۷۷۲۸۱۸۱-(۰۲۵)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="site-footer__branch">
|
||||
<p className="site-footer__branch-title">اطلاعات تماس شعبه دوم</p>
|
||||
<div>
|
||||
<p className="site-footer__label">آدرس ما</p>
|
||||
<p className="site-footer__value">قم،بلوار الغدیر،روبروی دانشگاه قم</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="site-footer__label">شماره تماس</p>
|
||||
<p className="site-footer__value">۳۲۸۵۸۱۸۱-(۰۲۵)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="site-footer__bottom">
|
||||
<div className="site-footer__social">
|
||||
<a href="https://t.me/" aria-label="تلگرام" target="_blank" rel="noreferrer">
|
||||
<Send size={24} strokeWidth={1.5} />
|
||||
</a>
|
||||
<a href="https://instagram.com/" aria-label="اینستاگرام" target="_blank" rel="noreferrer">
|
||||
<Instagram size={24} strokeWidth={1.5} />
|
||||
</a>
|
||||
</div>
|
||||
<p className="site-footer__copy">کلیه حقوق این سایت محفوظ و متعلق به شیرینی بلوط است.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
.site-header {
|
||||
padding-block: 20px;
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.site-header__logo img {
|
||||
width: 60px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.site-header__nav ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.site-header__dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-link {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--color-secondary);
|
||||
font-weight: 300;
|
||||
font-size: 15px;
|
||||
transition: color 0.3s ease-in-out, font-weight 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.menu-link__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.menu-link:hover {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.menu-link.active-link {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.menu-link.active-link::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-chevron {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.site-header__dropdown-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
padding-top: 14px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.site-header__dropdown:hover .site-header__dropdown-panel,
|
||||
.site-header__dropdown:focus-within .site-header__dropdown-panel {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.site-header__dropdown-inner {
|
||||
padding: 10px 0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(143, 65, 12, 0.12);
|
||||
border: 1px solid rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.site-header__dropdown-item {
|
||||
display: block;
|
||||
padding: 10px 18px;
|
||||
color: var(--color-secondary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.site-header__dropdown-item:hover {
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.site-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.site-header__actions a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-primary);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.site-header__actions a:hover,
|
||||
.site-header__cart:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.site-header__cart {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-header__cart-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: -8px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.site-header__user-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.site-header__burger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.site-header__mobile {
|
||||
display: none;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.site-header__mobile ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.site-header__mobile-sub {
|
||||
margin-top: 8px;
|
||||
padding-inline-start: 14px;
|
||||
gap: 10px !important;
|
||||
}
|
||||
|
||||
.site-header__mobile-sub a {
|
||||
color: var(--color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-header__mobile-user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.site-header {
|
||||
padding-block: 14px;
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-header__logo img {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.site-header__nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header__burger {
|
||||
display: flex;
|
||||
color: var(--color-primary);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.site-header__mobile {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding: 14px 16px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(143, 65, 12, 0.1);
|
||||
}
|
||||
|
||||
.site-header__mobile ul {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-header__mobile .menu-link {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.site-header__mobile .menu-link::before,
|
||||
.site-header__mobile-sub a::before,
|
||||
.site-header__mobile-user::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.site-header__mobile .menu-link.active-link::before {
|
||||
opacity: 1;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.site-header__mobile .menu-link.active-link::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header__mobile-sub {
|
||||
margin-top: 6px;
|
||||
padding-inline-start: 18px;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.site-header__mobile-sub a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.site-header__mobile-user::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header__actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Keep cart + account; hide search on compact header */
|
||||
.site-header__actions a[aria-label='جستجو'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header__user-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header__cart,
|
||||
.site-header__user {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.site-header__actions {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState, type MouseEvent } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronDown, Menu, Search, ShoppingCart, User, X } from 'lucide-react'
|
||||
import logo from '../assets/images/logo.png'
|
||||
import { listCategories } from '../lib/api'
|
||||
import type { CategoryNode } from '../lib/types'
|
||||
import {
|
||||
consumeAuthHashFromUrl,
|
||||
displayName,
|
||||
getAuthUser,
|
||||
getCustomerDashboardUrl,
|
||||
getCustomerLoginUrl,
|
||||
type AuthUser,
|
||||
} from '../lib/auth'
|
||||
import { getCartCount, openCartDrawer, subscribeCart } from '../lib/cart'
|
||||
import './Header.css'
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: '/', label: 'صفحه اصلی' },
|
||||
{ to: '/products', label: 'محصولات', hasDropdown: true },
|
||||
{ to: '/about-us', label: 'درباره ما' },
|
||||
{ to: '/contact-us', label: 'تماس با ما' },
|
||||
{ to: '/quick-info', label: 'اطلاعات مجموعه' },
|
||||
]
|
||||
|
||||
function isActivePath(pathname: string, to: string) {
|
||||
return to === '/' ? pathname === '/' : pathname === to || pathname.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { pathname } = useLocation()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [topCategories, setTopCategories] = useState<CategoryNode[]>([])
|
||||
const [user, setUser] = useState<AuthUser | null>(() => getAuthUser())
|
||||
const [cartCount, setCartCount] = useState(() => getCartCount())
|
||||
|
||||
useEffect(() => {
|
||||
consumeAuthHashFromUrl()
|
||||
setUser(getAuthUser())
|
||||
}, [])
|
||||
|
||||
useEffect(() => subscribeCart(() => setCartCount(getCartCount())), [])
|
||||
|
||||
useEffect(() => {
|
||||
listCategories()
|
||||
.then((nodes) => setTopCategories(nodes.filter((n) => !n.parentId)))
|
||||
.catch(() => setTopCategories([]))
|
||||
}, [])
|
||||
|
||||
function handleUserClick(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
if (user) {
|
||||
window.location.assign(getCustomerDashboardUrl('/'))
|
||||
return
|
||||
}
|
||||
window.location.assign(getCustomerLoginUrl(`${window.location.origin}/`))
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="max-container">
|
||||
<div className="site-header__inner">
|
||||
<Link to="/" className="site-header__logo" onClick={() => setMobileOpen(false)}>
|
||||
<img src={logo} alt="لوگو" width={60} height={68} />
|
||||
</Link>
|
||||
|
||||
<nav className="site-header__nav">
|
||||
<ul>
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = isActivePath(pathname, link.to)
|
||||
if (link.hasDropdown) {
|
||||
return (
|
||||
<li key={link.to} className="site-header__dropdown">
|
||||
<Link
|
||||
to={link.to}
|
||||
className={`menu-link${active ? ' active-link' : ''}`}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span className="menu-link__label">
|
||||
{link.label}
|
||||
<ChevronDown className="menu-chevron" size={12} />
|
||||
</span>
|
||||
</Link>
|
||||
<div className="site-header__dropdown-panel">
|
||||
<div className="site-header__dropdown-inner">
|
||||
<Link
|
||||
to="/products"
|
||||
className="site-header__dropdown-item"
|
||||
>
|
||||
همه محصولات
|
||||
</Link>
|
||||
{topCategories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to={`/products/${cat.slug}`}
|
||||
className="site-header__dropdown-item"
|
||||
>
|
||||
{cat.nameFa}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className={`menu-link${active ? ' active-link' : ''}`}
|
||||
>
|
||||
<span className="menu-link__label">{link.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="site-header__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="site-header__cart"
|
||||
aria-label="سبد خرید"
|
||||
onClick={() => openCartDrawer()}
|
||||
>
|
||||
<ShoppingCart size={22} strokeWidth={1.5} />
|
||||
{cartCount > 0 && (
|
||||
<span className="site-header__cart-badge">
|
||||
{cartCount.toLocaleString('fa-IR')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Link to="/search" aria-label="جستجو">
|
||||
<Search size={22} strokeWidth={1.5} />
|
||||
</Link>
|
||||
<a
|
||||
href={user ? getCustomerDashboardUrl('/') : getCustomerLoginUrl(`${window.location.origin}/`)}
|
||||
className="site-header__user"
|
||||
aria-label={user ? 'پنل کاربری' : 'ورود'}
|
||||
onClick={handleUserClick}
|
||||
>
|
||||
<User size={22} strokeWidth={1.5} />
|
||||
{user && <span className="site-header__user-name">{displayName(user)}</span>}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="site-header__burger"
|
||||
aria-label="منو"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
{mobileOpen ? <X size={24} strokeWidth={1.5} /> : <Menu size={24} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<nav className="site-header__mobile">
|
||||
<ul>
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = isActivePath(pathname, link.to)
|
||||
return (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className={`menu-link${active ? ' active-link' : ''}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<span className="menu-link__label">{link.label}</span>
|
||||
</Link>
|
||||
{link.hasDropdown && (
|
||||
<ul className="site-header__mobile-sub">
|
||||
<li>
|
||||
<Link to="/products" onClick={() => setMobileOpen(false)}>
|
||||
همه محصولات
|
||||
</Link>
|
||||
</li>
|
||||
{topCategories.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<Link
|
||||
to={`/products/${cat.slug}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{cat.nameFa}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li>
|
||||
<a
|
||||
href={user ? getCustomerDashboardUrl('/') : getCustomerLoginUrl(`${window.location.origin}/`)}
|
||||
className="site-header__mobile-user"
|
||||
onClick={(e) => {
|
||||
setMobileOpen(false)
|
||||
handleUserClick(e)
|
||||
}}
|
||||
>
|
||||
<User size={18} strokeWidth={1.5} />
|
||||
{user ? displayName(user) : 'ورود'}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
type HeaderTitleProps = {
|
||||
placeholder: string
|
||||
secondary: string
|
||||
primary: string
|
||||
size?: 'md' | 'lg' | 'xl'
|
||||
align?: 'center' | 'start' | 'separated'
|
||||
/** When true, primary (larger) line is rendered above secondary */
|
||||
primaryFirst?: boolean
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export default function HeaderTitle({
|
||||
placeholder,
|
||||
secondary,
|
||||
primary,
|
||||
size = 'lg',
|
||||
align = 'center',
|
||||
primaryFirst = false,
|
||||
className = '',
|
||||
style,
|
||||
}: HeaderTitleProps) {
|
||||
const layoutClass =
|
||||
align === 'center'
|
||||
? 'text-center texts--overlap'
|
||||
: align === 'separated'
|
||||
? 'text-start texts--separated'
|
||||
: 'text-start texts--overlap'
|
||||
|
||||
return (
|
||||
<div className={`${layoutClass} size-${size} ${className}`} style={style}>
|
||||
<div className="header-title">
|
||||
<p className="header-title__placeholder">{placeholder}</p>
|
||||
<div className="header-title--top">
|
||||
{primaryFirst ? (
|
||||
<>
|
||||
<p className="header-title__primary">{primary}</p>
|
||||
<p className="header-title__secondary">{secondary}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="header-title__secondary">{secondary}</p>
|
||||
<p className="header-title__primary">{primary}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
.offers-hours {
|
||||
margin-top: 300px;
|
||||
}
|
||||
|
||||
.offers-hours--compact {
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
.offers-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-image: url('../assets/images/gradiantCircle.png');
|
||||
background-position: center top;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.offers-wrapper--hours-only {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.offers-wrapper__img {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.offers-wrapper__img img {
|
||||
width: 400px;
|
||||
max-width: 70vw;
|
||||
}
|
||||
|
||||
.offers-wrapper__title {
|
||||
padding-top: 150px;
|
||||
}
|
||||
|
||||
.offers {
|
||||
margin: 20px auto;
|
||||
max-width: 900px;
|
||||
padding: 0 50px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.offers__item + .offers__item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.offers__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(94, 94, 94, 0.2);
|
||||
padding: 14px 0;
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(16px, 3vw, 22px);
|
||||
font-weight: 300;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.offers__trigger--open {
|
||||
color: var(--color-secondary);
|
||||
border-bottom: none;
|
||||
font-size: clamp(18px, 4vw, 25px);
|
||||
font-weight: 400;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.offers__body {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(143, 65, 12, 0.12);
|
||||
}
|
||||
|
||||
.offers__description {
|
||||
font-size: clamp(13px, 3vw, 16px);
|
||||
text-align: justify;
|
||||
line-height: 1.9;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.offers__cta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.semicolon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 40px 0 20px;
|
||||
}
|
||||
|
||||
.semicolon--solo {
|
||||
margin: 100px 0 20px;
|
||||
}
|
||||
|
||||
.semicolon img {
|
||||
max-width: 24px;
|
||||
width: 5vw;
|
||||
}
|
||||
|
||||
.hours {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(14px, 3vw, 19px);
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hours__number {
|
||||
background: linear-gradient(to right, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: clamp(32px, 4vw, 64px);
|
||||
margin-inline: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.offers-hours {
|
||||
margin-top: 200px;
|
||||
}
|
||||
|
||||
.offers-hours--compact {
|
||||
margin-top: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.offers {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.offers-wrapper__img img {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.offers-wrapper__title {
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.offers-hours {
|
||||
margin-top: 120px;
|
||||
}
|
||||
|
||||
.offers-hours--compact {
|
||||
margin-top: 64px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import HeaderTitle from './HeaderTitle'
|
||||
import cookies from '../assets/images/cookies.png'
|
||||
import flatLay from '../assets/images/FlatLay.png'
|
||||
import './OffersHours.css'
|
||||
|
||||
const OFFER_CATEGORIES = [
|
||||
{
|
||||
title: 'شیرینی',
|
||||
description:
|
||||
'شیرینیهای بلوط همیشه تازه، خوشطعم و باکیفیت تهیه میشوند 🌰 از مدلهای ساده برای دورهمیهای خانوادگی تا شیرینیهای شیک و مجلسی، همه با بهترین مواد اولیه آماده میشوند تا کنار هر فنجان چای و هر جشن، لحظههایتان شیرینتر شود.',
|
||||
},
|
||||
{ title: 'كيكی جات' },
|
||||
{ title: 'شیرینی تر' },
|
||||
{ title: 'شیرینی خشک' },
|
||||
{ title: 'زولبیا و بامیه' },
|
||||
{ title: 'کیک شکلاتی' },
|
||||
{ title: 'کیک' },
|
||||
{ title: 'وسایل تم تولد' },
|
||||
{ title: 'سوهان' },
|
||||
{ title: 'آجیل و شکلات' },
|
||||
{ title: 'دسر ها' },
|
||||
{ title: 'شیرینی ها' },
|
||||
{ title: 'کیک های خامه ای' },
|
||||
]
|
||||
|
||||
type OffersHoursProps = {
|
||||
showOffers?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function OffersHours({ showOffers = true, className = '' }: OffersHoursProps) {
|
||||
const [openOffer, setOpenOffer] = useState(0)
|
||||
|
||||
return (
|
||||
<div className={`offers-hours ${className}`.trim()}>
|
||||
<section className={`offers-wrapper${showOffers ? '' : ' offers-wrapper--hours-only'}`}>
|
||||
{showOffers && (
|
||||
<>
|
||||
<div className="offers-wrapper__img">
|
||||
<img src={cookies} alt="کوکی" />
|
||||
</div>
|
||||
|
||||
<HeaderTitle
|
||||
placeholder="Special Menu Offers"
|
||||
secondary="پیشنهاد ویژه"
|
||||
primary="مارو از دست ندید!"
|
||||
size="lg"
|
||||
align="center"
|
||||
className="offers-wrapper__title"
|
||||
/>
|
||||
|
||||
<div className="offers">
|
||||
{OFFER_CATEGORIES.map((cat, index) => {
|
||||
const isOpen = openOffer === index
|
||||
return (
|
||||
<div key={cat.title} className="offers__item">
|
||||
<button
|
||||
type="button"
|
||||
className={`offers__trigger${isOpen ? ' offers__trigger--open' : ''}`}
|
||||
onClick={() => setOpenOffer(isOpen ? -1 : index)}
|
||||
>
|
||||
<span className="star">✱</span>
|
||||
<span>{cat.title}</span>
|
||||
</button>
|
||||
{isOpen && cat.description && (
|
||||
<div className="offers__body">
|
||||
<p className="offers__description">{cat.description}</p>
|
||||
<div className="offers__cta">
|
||||
<Link to="/products" className="btn-primary">
|
||||
مشاهده محصولات
|
||||
<ChevronLeft size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`semicolon${showOffers ? '' : ' semicolon--solo'}`}>
|
||||
<img src={flatLay} alt="" />
|
||||
</div>
|
||||
|
||||
<HeaderTitle
|
||||
placeholder="Working Hours"
|
||||
secondary="ساعت کاری"
|
||||
primary="کافه نان و شیرینی بلوط"
|
||||
size="lg"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="hours">
|
||||
<div>
|
||||
<span className="star">✱</span>
|
||||
شنبه تا پنجشنبه <span className="hours__number">۸</span> صبـــــــــح /{' '}
|
||||
<span className="hours__number">۱۲</span> شـــــــــــب
|
||||
</div>
|
||||
<div>
|
||||
<span className="star">✱</span>
|
||||
جمعه و تعطیلات <span className="hours__number">۹</span> صبـــــــــح /{' '}
|
||||
<span className="hours__number">۱۲</span> شـــــــــــب
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header__icon {
|
||||
width: 115px;
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-header__breadcrumbs ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
padding: 16px 12px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
font-size: clamp(10px, 2vw, 14px);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.page-header__breadcrumbs li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header__breadcrumbs a {
|
||||
color: inherit;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.page-header__breadcrumbs a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-header__breadcrumbs-divider {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.page-header__breadcrumbs-current {
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.page-header__breadcrumbs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header__icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header__title-row {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import './PageHeader.css'
|
||||
|
||||
type Crumb = { label: string; to?: string }
|
||||
|
||||
type PageHeaderProps = {
|
||||
icon: string
|
||||
iconAlt?: string
|
||||
placeholder: string
|
||||
secondary: string
|
||||
primary: string
|
||||
crumbs: Crumb[]
|
||||
fluid?: boolean
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
icon,
|
||||
iconAlt = '',
|
||||
placeholder,
|
||||
secondary,
|
||||
primary,
|
||||
crumbs,
|
||||
fluid = false,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={`page-header max-container${fluid ? ' max-container--fluid' : ''}`}>
|
||||
<div className="page-header__title-row">
|
||||
<img src={icon} alt={iconAlt} className="page-header__icon" width={115} />
|
||||
<div className="text-start texts--separated size-md">
|
||||
<div className="header-title">
|
||||
<p className="header-title__placeholder">{placeholder}</p>
|
||||
<div className="header-title--top">
|
||||
<p className="header-title__secondary">{secondary}</p>
|
||||
<p className="header-title__primary">{primary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="page-header__breadcrumbs" aria-label="مسیر صفحه">
|
||||
<ol>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<li key={crumb.label}>
|
||||
{index > 0 && (
|
||||
<span className="page-header__breadcrumbs-divider" aria-hidden="true">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{crumb.to ? (
|
||||
<Link to={crumb.to}>{crumb.label}</Link>
|
||||
) : (
|
||||
<span className="page-header__breadcrumbs-current">{crumb.label}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.store-card {
|
||||
display: block;
|
||||
background-color: rgba(143, 65, 12, 0.05);
|
||||
border-radius: 100px 100px 25px 25px;
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.store-card:hover {
|
||||
background-color: rgba(143, 65, 12, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.store-card__image {
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-card__image img {
|
||||
border-radius: 90px 90px 0 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.store-card__description {
|
||||
margin-top: 7px;
|
||||
padding-inline: 4px 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.store-card__title {
|
||||
color: var(--color-gray-400);
|
||||
font-size: clamp(13px, 3vw, 18px);
|
||||
min-height: 48px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-card__subtitle {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(15px, 3vw, 20px);
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.store-card__subtitle-detail {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(8px, 2vw, 10px);
|
||||
}
|
||||
|
||||
.store-card__subtitle-weight {
|
||||
color: rgba(94, 94, 94, 0.5);
|
||||
font-size: clamp(10px, 2vw, 13px);
|
||||
}
|
||||
|
||||
.store-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.store-card__arrow {
|
||||
color: var(--color-primary);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@media (max-width: 678px) {
|
||||
.store-card {
|
||||
border-radius: 60px 60px 15px 15px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.store-card__image img {
|
||||
border-radius: 50px 50px 0 0;
|
||||
}
|
||||
|
||||
.store-card__title {
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.store-card__subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.store-card__actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import type { Product } from '../lib/types'
|
||||
import { formatProductPrice, productImageSrc } from '../lib/types'
|
||||
import defaultPhoto from '../assets/images/DefaultPhoto.png'
|
||||
import './ProductCard.css'
|
||||
|
||||
type ProductCardProps = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
const price = formatProductPrice(product)
|
||||
|
||||
return (
|
||||
<Link to={`/products/${product.id}`} className="store-card">
|
||||
<div className="store-card__image">
|
||||
<img src={productImageSrc(product, defaultPhoto)} alt={product.nameFa} />
|
||||
</div>
|
||||
<div className="store-card__description">
|
||||
<div className="store-card__title">{product.nameFa}</div>
|
||||
<div className="store-card__subtitle">
|
||||
<span>{price.amount}</span>
|
||||
<span className="store-card__subtitle-detail"> تومان</span>
|
||||
<span className="store-card__subtitle-weight"> /{price.unitLabel}</span>
|
||||
</div>
|
||||
<div className="store-card__actions">
|
||||
<span className="store-card__arrow" aria-hidden="true">
|
||||
<ChevronLeft size={18} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
.options-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(47, 24, 10, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.options-modal {
|
||||
width: min(440px, 100%);
|
||||
border-radius: 22px;
|
||||
background: #fffaf5;
|
||||
border: 1px solid rgba(143, 65, 12, 0.1);
|
||||
box-shadow: 0 24px 60px rgba(79, 35, 8, 0.2);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.options-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.options-modal__eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: rgba(143, 65, 12, 0.55);
|
||||
font-size: 12px;
|
||||
font-family: Cailyne, Georgia, serif;
|
||||
}
|
||||
|
||||
.options-modal__title {
|
||||
margin: 0;
|
||||
color: var(--color-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.options-modal__meta {
|
||||
margin: 6px 0 0;
|
||||
color: var(--color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.options-modal__close {
|
||||
border: none;
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
color: var(--color-primary);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.options-modal__fieldset {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.options-modal__fieldset legend {
|
||||
color: var(--color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.options-modal__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.options-modal__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.options-modal__chip.is-active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
}
|
||||
|
||||
.options-modal__chip input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.options-modal__check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.25);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.options-modal__chip.is-active .options-modal__check {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.options-modal__chip-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.options-modal__chip-price {
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.options-modal__preview {
|
||||
margin: 16px 0 0;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.options-modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.options-modal__cancel {
|
||||
border: 1px solid rgba(143, 65, 12, 0.15);
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.options-modal-overlay {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
place-items: end center;
|
||||
}
|
||||
|
||||
.options-modal {
|
||||
width: 100%;
|
||||
max-height: min(92vh, 100%);
|
||||
overflow: auto;
|
||||
border-radius: 22px 22px 0 0;
|
||||
padding: 18px 16px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.options-modal__actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.options-modal__actions .btn-primary,
|
||||
.options-modal__cancel {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { Check, ChevronLeft, X } from 'lucide-react'
|
||||
import type { Product, ProductOptionValue } from '../lib/types'
|
||||
import { formatPriceFa, formatProductPrice } from '../lib/types'
|
||||
import './ProductOptionsModal.css'
|
||||
|
||||
type ProductOptionsModalProps = {
|
||||
open: boolean
|
||||
product: Product
|
||||
quantity: number
|
||||
onClose: () => void
|
||||
onConfirm: (options: ProductOptionValue[]) => void
|
||||
}
|
||||
|
||||
function optionLabel(option: ProductOptionValue) {
|
||||
const flavor = option.flavor?.nameFa?.trim()
|
||||
if (flavor && option.amount) return `${flavor} — ${option.amount}`
|
||||
return flavor || option.amount || 'گزینه'
|
||||
}
|
||||
|
||||
export default function ProductOptionsModal({
|
||||
open,
|
||||
product,
|
||||
quantity,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ProductOptionsModalProps) {
|
||||
const titleId = useId()
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const options = product.options ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedIds([])
|
||||
}, [open, product.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const selected = options.filter((option) => selectedIds.includes(option.id))
|
||||
const optionsTotal = selected.reduce((sum, option) => sum + option.price, 0)
|
||||
const lineTotal = Math.round((product.price + optionsTotal) * quantity)
|
||||
const price = formatProductPrice(product)
|
||||
|
||||
function toggle(optionId: string) {
|
||||
setSelectedIds((current) =>
|
||||
current.includes(optionId)
|
||||
? current.filter((id) => id !== optionId)
|
||||
: [...current, optionId],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="options-modal-overlay" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="options-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="options-modal__header">
|
||||
<div>
|
||||
<p className="options-modal__eyebrow">Product Options</p>
|
||||
<h2 id={titleId} className="options-modal__title">
|
||||
{product.nameFa}
|
||||
</h2>
|
||||
<p className="options-modal__meta">
|
||||
{price.amount} تومان / {price.unitLabel}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="options-modal__close" onClick={onClose} aria-label="بستن">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<fieldset className="options-modal__fieldset">
|
||||
<legend>گزینهها</legend>
|
||||
<div className="options-modal__list">
|
||||
{options.map((option) => {
|
||||
const checked = selectedIds.includes(option.id)
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className={`options-modal__chip${checked ? ' is-active' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(option.id)}
|
||||
/>
|
||||
<span className="options-modal__check" aria-hidden>
|
||||
{checked ? <Check size={14} strokeWidth={2.25} /> : null}
|
||||
</span>
|
||||
<span className="options-modal__chip-text">
|
||||
<span>{optionLabel(option)}</span>
|
||||
{option.price > 0 && (
|
||||
<span className="options-modal__chip-price">
|
||||
+{formatPriceFa(option.price)} تومان
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p className="options-modal__preview">
|
||||
جمع: {formatPriceFa(lineTotal)} تومان
|
||||
</p>
|
||||
|
||||
<div className="options-modal__actions">
|
||||
<button type="button" className="options-modal__cancel" onClick={onClose}>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => onConfirm(selected)}
|
||||
>
|
||||
افزودن به سبد خرید
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
left: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-card {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||
box-shadow: 0 16px 40px rgba(79, 35, 8, 0.14);
|
||||
animation: toast-in 0.28s ease;
|
||||
}
|
||||
|
||||
.toast-card__icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.toast-card__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-card__message {
|
||||
margin: 0;
|
||||
color: var(--color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-card__detail {
|
||||
margin: 4px 0 0;
|
||||
color: var(--color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-card__close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-gray-300);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.toast-host {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, X } from 'lucide-react'
|
||||
import { TOAST_EVENT, type ToastPayload } from '../lib/cart'
|
||||
import './ToastHost.css'
|
||||
|
||||
type ToastItem = ToastPayload & { id: number }
|
||||
|
||||
export default function ToastHost() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
function onToast(event: Event) {
|
||||
const custom = event as CustomEvent<ToastPayload>
|
||||
const id = Date.now() + Math.random()
|
||||
const payload = custom.detail
|
||||
if (!payload?.message) return
|
||||
setToasts((current) => [...current, { ...payload, id }])
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
window.addEventListener(TOAST_EVENT, onToast)
|
||||
return () => window.removeEventListener(TOAST_EVENT, onToast)
|
||||
}, [])
|
||||
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="toast-host" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="toast-card">
|
||||
<div className="toast-card__icon" aria-hidden>
|
||||
<CheckCircle2 size={22} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="toast-card__body">
|
||||
<p className="toast-card__message">{toast.message}</p>
|
||||
{toast.detail && <p className="toast-card__detail">{toast.detail}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-card__close"
|
||||
aria-label="بستن"
|
||||
onClick={() =>
|
||||
setToasts((current) => current.filter((item) => item.id !== toast.id))
|
||||
}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
@font-face {
|
||||
font-family: Doran;
|
||||
src: url('./assets/fonts/Doran-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Doran;
|
||||
src: url('./assets/fonts/Doran-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Doran;
|
||||
src: url('./assets/fonts/Doran-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Doran;
|
||||
src: url('./assets/fonts/Doran-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Cailyne;
|
||||
src: url('./assets/fonts/Cailyne.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #fff7f1;
|
||||
--color-primary: #8f410c;
|
||||
--color-secondary: #4b4b4b;
|
||||
--color-gray-100: #d9d9d9;
|
||||
--color-gray-300: #7f7f7f;
|
||||
--color-gray-400: #5e5e5e;
|
||||
--max-container: 1280px;
|
||||
--max-container-fluid: 1800px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Doran, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
background: var(--color-bg);
|
||||
color: #000;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.max-container {
|
||||
width: 100%;
|
||||
max-width: var(--max-container);
|
||||
margin-inline: auto;
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.max-container--fluid {
|
||||
max-width: var(--max-container-fluid);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.max-container {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.size-xl .header-title__primary,
|
||||
.size-lg .header-title__primary,
|
||||
.size-md .header-title__primary {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.size-xl .header-title__secondary,
|
||||
.size-lg .header-title__secondary,
|
||||
.size-md .header-title__secondary {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.size-xl .header-title__placeholder {
|
||||
font-size: clamp(64px, 22vw, 120px);
|
||||
}
|
||||
|
||||
.size-xl .header-title__primary {
|
||||
font-size: clamp(28px, 9vw, 40px);
|
||||
}
|
||||
|
||||
.size-xl .header-title__secondary {
|
||||
font-size: clamp(13px, 3.6vw, 18px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
min-height: 44px;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.max-container {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header title (English watermark + Persian overlay) */
|
||||
.header-title {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-title__placeholder {
|
||||
margin: 0 !important;
|
||||
color: var(--color-primary);
|
||||
font-family: Cailyne, serif;
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-title--top {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.header-title__primary {
|
||||
margin: 0;
|
||||
color: var(--color-primary);
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.header-title__secondary {
|
||||
margin: 0;
|
||||
color: var(--color-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.texts--overlap .header-title--top {
|
||||
right: 50%;
|
||||
top: 50%;
|
||||
transform: translate(50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.texts--separated .header-title--top {
|
||||
right: 0;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.text-start.texts--overlap .header-title--top {
|
||||
right: 0;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.text-start .header-title {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.size-md .header-title__placeholder {
|
||||
font-size: clamp(50px, 10vw, 85px);
|
||||
}
|
||||
|
||||
.size-md .header-title__primary {
|
||||
font-size: clamp(25px, 6vw, 45px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.size-md .header-title__secondary {
|
||||
font-size: clamp(20px, 5vw, 35px);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.size-xl .header-title__placeholder {
|
||||
font-size: clamp(120px, 10vw, 200px);
|
||||
}
|
||||
|
||||
.size-xl .header-title__primary {
|
||||
font-size: clamp(35px, 6vw, 65px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.size-xl .header-title__secondary {
|
||||
font-size: clamp(16px, 5vw, 25px);
|
||||
white-space: nowrap;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.size-lg .header-title__placeholder {
|
||||
font-size: clamp(60px, 10vw, 128px);
|
||||
}
|
||||
|
||||
.size-lg .header-title__primary {
|
||||
font-size: clamp(28px, 6vw, 45px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.size-lg .header-title__secondary {
|
||||
font-size: clamp(23px, 5vw, 35px);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Primary button */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: rgba(143, 65, 12, 0.2);
|
||||
font-size: clamp(14px, 4vw, 18px);
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getAccessToken } from './auth'
|
||||
import type { CategoryNode, Product, ProductListResponse } from './types'
|
||||
|
||||
const API_BASE =
|
||||
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ||
|
||||
'http://localhost:3100/api/v1'
|
||||
|
||||
export type ListProductsParams = {
|
||||
q?: string
|
||||
categoryId?: string
|
||||
categorySlug?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type UserAddress = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
}
|
||||
|
||||
export type Branch = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
}
|
||||
|
||||
export type ShippingException = {
|
||||
id: string
|
||||
district: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type Discount = {
|
||||
id: string
|
||||
code: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
active: boolean
|
||||
expired?: boolean
|
||||
categoryId?: string | null
|
||||
}
|
||||
|
||||
export type CreateMyOrderPayload = {
|
||||
deliveryType: 'pickup' | 'shipping'
|
||||
branchId?: string
|
||||
shippingAddressId?: string
|
||||
note?: string
|
||||
discountCode?: string
|
||||
items: Array<{
|
||||
productId: string
|
||||
quantity: number
|
||||
optionIds?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
async function parseError(response: Response) {
|
||||
const text = await response.text().catch(() => '')
|
||||
try {
|
||||
const json = JSON.parse(text) as { message?: string | string[] }
|
||||
if (Array.isArray(json.message)) return json.message.join('، ')
|
||||
if (typeof json.message === 'string') return json.message
|
||||
} catch {
|
||||
// plain text
|
||||
}
|
||||
return text || `Request failed (${response.status})`
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string, auth = false): Promise<T> {
|
||||
const headers: HeadersInit = {}
|
||||
if (auth) {
|
||||
const token = getAccessToken()
|
||||
if (!token) throw new Error('لطفاً وارد حساب کاربری شوید')
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, { headers })
|
||||
if (!response.ok) throw new Error(await parseError(response))
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function apiSend<T>(
|
||||
path: string,
|
||||
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const token = getAccessToken()
|
||||
if (!token) throw new Error('لطفاً وارد حساب کاربری شوید')
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!response.ok) throw new Error(await parseError(response))
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildQuery(params: ListProductsParams) {
|
||||
const search = new URLSearchParams()
|
||||
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.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))
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export function listProducts(params: ListProductsParams = {}) {
|
||||
return apiGet<ProductListResponse>(`/products${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return apiGet<Product>(`/products/${id}`)
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return apiGet<CategoryNode[]>('/categories')
|
||||
}
|
||||
|
||||
export function listMyAddresses() {
|
||||
return apiGet<UserAddress[]>('/users/me/addresses', true)
|
||||
}
|
||||
|
||||
export function createMyAddress(payload: {
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
}) {
|
||||
return apiSend<UserAddress>('/users/me/addresses', 'POST', payload)
|
||||
}
|
||||
|
||||
export function listBranches() {
|
||||
return apiGet<Branch[]>('/settings/branches', true)
|
||||
}
|
||||
|
||||
export function listDistricts() {
|
||||
return apiGet<string[]>('/settings/districts', true)
|
||||
}
|
||||
|
||||
export function listShipping() {
|
||||
return apiGet<ShippingException[]>('/settings/shipping', true)
|
||||
}
|
||||
|
||||
export function listMyDiscounts(pageSize = 50) {
|
||||
return apiGet<{ items: Discount[] }>(`/discounts/mine?pageSize=${pageSize}`, true)
|
||||
}
|
||||
|
||||
export function createMyOrder(payload: CreateMyOrderPayload) {
|
||||
return apiSend<{ id: string; code: string; totalPrice: number }>(
|
||||
'/orders/mine',
|
||||
'POST',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { UserRole } from './types'
|
||||
|
||||
const AUTH_COOKIE = 'balout.auth'
|
||||
const AUTH_HASH_PREFIX = '#balout_auth='
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
title: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
cellNumber: string
|
||||
role: UserRole
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AuthSession = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
function isSession(value: unknown): value is AuthSession {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const session = value as Partial<AuthSession>
|
||||
return (
|
||||
typeof session.accessToken === 'string' &&
|
||||
typeof session.refreshToken === 'string' &&
|
||||
!!session.user &&
|
||||
typeof session.user === 'object' &&
|
||||
typeof session.user.name === 'string' &&
|
||||
typeof session.user.id === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function cookieDomain() {
|
||||
const host = window.location.hostname
|
||||
if (host === 'localhost' || host === '127.0.0.1') return undefined
|
||||
const configured = import.meta.env.VITE_COOKIE_DOMAIN as string | undefined
|
||||
if (configured) return configured
|
||||
if (host.endsWith('.baloutpastry.com') || host === 'baloutpastry.com') {
|
||||
return '.baloutpastry.com'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readCookie(name: string) {
|
||||
const prefix = `${encodeURIComponent(name)}=`
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return decodeURIComponent(trimmed.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string, maxAgeSeconds: number) {
|
||||
const domain = cookieDomain()
|
||||
const parts = [
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
'SameSite=Lax',
|
||||
]
|
||||
if (domain) parts.push(`Domain=${domain}`)
|
||||
document.cookie = parts.join('; ')
|
||||
}
|
||||
|
||||
function clearCookie(name: string) {
|
||||
const domain = cookieDomain()
|
||||
const parts = [
|
||||
`${encodeURIComponent(name)}=`,
|
||||
'Path=/',
|
||||
'Max-Age=0',
|
||||
'SameSite=Lax',
|
||||
]
|
||||
if (domain) parts.push(`Domain=${domain}`)
|
||||
document.cookie = parts.join('; ')
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
try {
|
||||
const raw = readCookie(AUTH_COOKIE)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isSession(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession) {
|
||||
writeCookie(AUTH_COOKIE, JSON.stringify(session), 60 * 60 * 24 * 7)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
clearCookie(AUTH_COOKIE)
|
||||
}
|
||||
|
||||
export function getAuthUser(): AuthUser | null {
|
||||
return getSession()?.user ?? null
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return getSession()?.accessToken ?? null
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getSession() !== null
|
||||
}
|
||||
|
||||
/** Import session handed off from customer login via URL hash. */
|
||||
export function consumeAuthHashFromUrl() {
|
||||
const hash = window.location.hash
|
||||
if (!hash.startsWith(AUTH_HASH_PREFIX)) return false
|
||||
try {
|
||||
const raw = decodeURIComponent(hash.slice(AUTH_HASH_PREFIX.length))
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (isSession(parsed)) {
|
||||
setSession(parsed)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
} finally {
|
||||
const clean = `${window.location.pathname}${window.location.search}`
|
||||
window.history.replaceState(null, '', clean)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCustomerAppUrl() {
|
||||
return (
|
||||
(import.meta.env.VITE_CUSTOMER_APP_URL as string | undefined)?.replace(/\/$/, '') ||
|
||||
'http://customer.baloutpastry.com:5173'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCustomerLoginUrl(returnUrl = window.location.origin + '/') {
|
||||
const url = new URL(`${getCustomerAppUrl()}/login`)
|
||||
url.searchParams.set('returnUrl', returnUrl)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function getCustomerDashboardUrl(path = '/') {
|
||||
const base = getCustomerAppUrl()
|
||||
return `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export function displayName(user: AuthUser) {
|
||||
const short = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
return short || user.name
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Product, ProductOptionValue, SellUnit } from './types'
|
||||
import { formatPriceFa } from './types'
|
||||
|
||||
const CART_KEY = 'balout.cart'
|
||||
export const CART_EVENT = 'balout:cart-change'
|
||||
export const TOAST_EVENT = 'balout:toast'
|
||||
|
||||
export type CartOption = {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type CartItem = {
|
||||
key: string
|
||||
productId: string
|
||||
nameFa: string
|
||||
imageUrl: string | null
|
||||
sellUnit: SellUnit
|
||||
basePrice: number
|
||||
quantity: number
|
||||
options: CartOption[]
|
||||
}
|
||||
|
||||
export type ToastPayload = {
|
||||
message: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function optionLabel(option: ProductOptionValue) {
|
||||
const flavor = option.flavor?.nameFa?.trim()
|
||||
if (flavor && option.amount) return `${flavor} — ${option.amount}`
|
||||
return flavor || option.amount || 'گزینه'
|
||||
}
|
||||
|
||||
function itemKey(productId: string, optionIds: string[]) {
|
||||
const sorted = [...optionIds].sort().join(',')
|
||||
return sorted ? `${productId}::${sorted}` : productId
|
||||
}
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CART_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return Array.isArray(parsed) ? (parsed as CartItem[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeCart(items: CartItem[]) {
|
||||
localStorage.setItem(CART_KEY, JSON.stringify(items))
|
||||
window.dispatchEvent(new CustomEvent(CART_EVENT))
|
||||
}
|
||||
|
||||
export function getCartItems() {
|
||||
return readCart()
|
||||
}
|
||||
|
||||
export function getCartSubtotal() {
|
||||
return readCart().reduce((sum, item) => sum + cartItemLineTotal(item), 0)
|
||||
}
|
||||
|
||||
export function getCartCount() {
|
||||
return readCart().length
|
||||
}
|
||||
|
||||
export function setCartItemQuantity(key: string, quantity: number) {
|
||||
const items = readCart()
|
||||
const item = items.find((row) => row.key === key)
|
||||
if (!item) return
|
||||
const min = item.sellUnit === 'kilo' ? 0.1 : 1
|
||||
if (quantity < min) {
|
||||
writeCart(items.filter((row) => row.key !== key))
|
||||
return
|
||||
}
|
||||
item.quantity = Number(quantity.toFixed(3))
|
||||
writeCart(items)
|
||||
}
|
||||
|
||||
export function removeCartItem(key: string) {
|
||||
writeCart(readCart().filter((item) => item.key !== key))
|
||||
}
|
||||
|
||||
export function clearCart() {
|
||||
writeCart([])
|
||||
}
|
||||
|
||||
export const CART_OPEN_EVENT = 'balout:cart-open'
|
||||
|
||||
export type CartOpenDetail = {
|
||||
step?: 1 | 2 | 3
|
||||
}
|
||||
|
||||
export function openCartDrawer(step: 1 | 2 | 3 = 1) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<CartOpenDetail>(CART_OPEN_EVENT, { detail: { step } }),
|
||||
)
|
||||
}
|
||||
|
||||
/** Login should send users back here to continue checkout. */
|
||||
export function getCartCheckoutReturnUrl() {
|
||||
const url = new URL(window.location.origin)
|
||||
url.searchParams.set('cart', 'checkout')
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function cartItemUnitPrice(item: CartItem) {
|
||||
const optionsTotal = item.options.reduce((sum, option) => sum + option.price, 0)
|
||||
return item.basePrice + optionsTotal
|
||||
}
|
||||
|
||||
export function cartItemLineTotal(item: CartItem) {
|
||||
return Math.round(cartItemUnitPrice(item) * item.quantity)
|
||||
}
|
||||
|
||||
export function addToCart(input: {
|
||||
product: Product
|
||||
quantity: number
|
||||
options?: ProductOptionValue[]
|
||||
}) {
|
||||
const options = (input.options ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
name: optionLabel(option),
|
||||
price: option.price,
|
||||
}))
|
||||
const key = itemKey(
|
||||
input.product.id,
|
||||
options.map((option) => option.id),
|
||||
)
|
||||
const items = readCart()
|
||||
const existing = items.find((item) => item.key === key)
|
||||
if (existing) {
|
||||
existing.quantity = Number((existing.quantity + input.quantity).toFixed(3))
|
||||
} else {
|
||||
items.push({
|
||||
key,
|
||||
productId: input.product.id,
|
||||
nameFa: input.product.nameFa,
|
||||
imageUrl: input.product.mainImageUrl,
|
||||
sellUnit: input.product.sellUnit,
|
||||
basePrice: input.product.price,
|
||||
quantity: input.quantity,
|
||||
options,
|
||||
})
|
||||
}
|
||||
writeCart(items)
|
||||
|
||||
const optionsHint =
|
||||
options.length > 0
|
||||
? options.map((option) => option.name).join('، ')
|
||||
: undefined
|
||||
showToast({
|
||||
message: `${input.product.nameFa} به سبد خرید اضافه شد`,
|
||||
detail: optionsHint
|
||||
? `${optionsHint} — ${formatPriceFa(input.quantity)} ${input.product.sellUnit === 'kilo' ? 'کیلو' : 'عدد'}`
|
||||
: `${formatPriceFa(input.quantity)} ${input.product.sellUnit === 'kilo' ? 'کیلو' : 'عدد'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function showToast(payload: ToastPayload) {
|
||||
window.dispatchEvent(new CustomEvent(TOAST_EVENT, { detail: payload }))
|
||||
}
|
||||
|
||||
export function subscribeCart(listener: () => void) {
|
||||
const handler = () => listener()
|
||||
window.addEventListener(CART_EVENT, handler)
|
||||
window.addEventListener('storage', handler)
|
||||
return () => {
|
||||
window.removeEventListener(CART_EVENT, handler)
|
||||
window.removeEventListener('storage', handler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Prisma cuid-like ids used for products (not category slugs). */
|
||||
export function isCuid(value: string) {
|
||||
return /^c[a-z0-9]{20,}$/i.test(value)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export type SellUnit = 'unit' | 'kilo'
|
||||
|
||||
export type UserRole = 'customer' | 'admin' | 'superAdmin'
|
||||
|
||||
export type ProductCategoryRef = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export type ProductOptionValue = {
|
||||
id: string
|
||||
flavorId: string
|
||||
amount: string
|
||||
price: number
|
||||
flavor?: {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductGalleryImage = {
|
||||
id?: string
|
||||
url: string
|
||||
storageKey?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
price: number
|
||||
sellUnit: SellUnit
|
||||
categoryId: string
|
||||
category: ProductCategoryRef
|
||||
intro?: string | null
|
||||
description?: string | null
|
||||
tags?: string[]
|
||||
mainImageUrl: string | null
|
||||
mainImageKey?: string | null
|
||||
gallery?: ProductGalleryImage[]
|
||||
options?: ProductOptionValue[]
|
||||
}
|
||||
|
||||
export type CategoryNode = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
slug: string
|
||||
parentId: string | null
|
||||
sortOrder: number
|
||||
children?: CategoryNode[]
|
||||
}
|
||||
|
||||
export type ProductListResponse = {
|
||||
items: Product[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
const sellUnitLabel: Record<SellUnit, string> = {
|
||||
unit: 'واحد',
|
||||
kilo: 'کیلو',
|
||||
}
|
||||
|
||||
export function formatPriceFa(price: number) {
|
||||
return price.toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
export function formatProductPrice(product: Product) {
|
||||
const unit = sellUnitLabel[product.sellUnit] ?? ''
|
||||
return {
|
||||
amount: formatPriceFa(product.price),
|
||||
unitLabel: unit,
|
||||
full: `${formatPriceFa(product.price)} تومان /${unit}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function productImageSrc(product: Product, fallback: string) {
|
||||
return product.mainImageUrl || fallback
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,238 @@
|
||||
.about-page__intro {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.about-page__intro-image {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.about-page__intro-image img {
|
||||
max-height: 500px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.about-page__description {
|
||||
margin-top: 24px;
|
||||
font-size: clamp(13px, 3vw, 16px);
|
||||
line-height: clamp(20px, 6vw, 25px);
|
||||
text-align: justify;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.stat-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 60px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-section__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-section__item {
|
||||
text-align: center;
|
||||
padding-inline: clamp(20px, 4vw, 48px);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.stat-section__item--number {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(37px, 4vw, 96px);
|
||||
font-weight: 300;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-section__item--label {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(12px, 3vw, 24px);
|
||||
font-weight: 300;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.stat-section__divider {
|
||||
width: 1px;
|
||||
height: 60px;
|
||||
background: rgba(143, 65, 12, 0.35);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.about-page__why-title {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.feature-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.feature-section__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.feature-section__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-section__item--align {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.feature-section__icon {
|
||||
width: 70px;
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-section__title {
|
||||
font-size: clamp(12px, 2vw, 29px);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.feature-section__title--secondary {
|
||||
color: var(--color-secondary);
|
||||
margin: 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.feature-section__title--primary {
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.feature-section__hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.35);
|
||||
margin: 8px 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feature-section__cake {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-section__cake img {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.about-page__footer-block {
|
||||
margin-top: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.about-page__intro {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-page__intro-image {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-section {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.feature-section {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.feature-section__col {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.feature-section__cake {
|
||||
grid-column: 1 / -1;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.feature-section__item {
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-section__icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-section__title {
|
||||
font-size: clamp(13px, 3.6vw, 16px);
|
||||
}
|
||||
|
||||
.feature-section__title--secondary {
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.feature-section__title--primary {
|
||||
font-size: 1.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feature-section__hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stat-section__divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.about-page__footer-block {
|
||||
margin-top: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.stat-section {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 32px;
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.stat-section__item {
|
||||
min-width: 45%;
|
||||
padding-inline: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-section {
|
||||
gap: 20px 10px;
|
||||
}
|
||||
|
||||
.feature-section__title {
|
||||
font-size: clamp(12px, 3.4vw, 15px);
|
||||
}
|
||||
|
||||
.about-page__footer-block {
|
||||
margin-top: 100px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect } from 'react'
|
||||
import HeaderTitle from '../components/HeaderTitle'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import OffersHours from '../components/OffersHours'
|
||||
import infoIcon from '../assets/images/Info.svg'
|
||||
import treeImage from '../assets/images/derakht-balout.png'
|
||||
import strawberryCake from '../assets/images/strawberry-cake-cream.png'
|
||||
import handbagIcon from '../assets/images/Handbag.svg'
|
||||
import wavesIcon from '../assets/images/Waves.svg'
|
||||
import cakeIcon from '../assets/images/Cake.svg'
|
||||
import boxIcon from '../assets/images/Box.svg'
|
||||
import './AboutUsPage.css'
|
||||
|
||||
const STATS = [
|
||||
{ number: '31', label: 'ارسال های هفتگی' },
|
||||
{ number: '43', label: 'پشتیبانی' },
|
||||
{ number: '67', label: 'کاربران خوشحال' },
|
||||
{ number: '23', label: 'تنوع محصول' },
|
||||
]
|
||||
|
||||
const FEATURES_RIGHT = [
|
||||
{
|
||||
icon: handbagIcon,
|
||||
secondary: 'تنوع',
|
||||
primary: 'بالای محصولات',
|
||||
},
|
||||
{
|
||||
icon: wavesIcon,
|
||||
secondary: 'پخت',
|
||||
primary: 'تـــــازه و روزانـــــه',
|
||||
},
|
||||
]
|
||||
|
||||
const FEATURES_LEFT = [
|
||||
{
|
||||
icon: cakeIcon,
|
||||
secondary: 'طرح های',
|
||||
primary: 'زیبــــــا و خــــــاص',
|
||||
},
|
||||
{
|
||||
icon: boxIcon,
|
||||
secondary: 'ارسال',
|
||||
primary: 'رایگان در شهر قم',
|
||||
},
|
||||
]
|
||||
|
||||
export default function AboutUsPage() {
|
||||
useEffect(() => {
|
||||
document.title = 'شیرینی بلوط - صفحه درباره ما'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="about-page">
|
||||
<PageHeader
|
||||
icon={infoIcon}
|
||||
placeholder="About Us"
|
||||
secondary="درباره"
|
||||
primary="ما بیشتر بدانید"
|
||||
crumbs={[
|
||||
{ label: 'صفحه اصلی', to: '/' },
|
||||
{ label: 'درباره ما' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="max-container about-page__intro">
|
||||
<div className="about-page__intro-image">
|
||||
<img src={treeImage} alt="درخت بلوط" />
|
||||
</div>
|
||||
<div className="about-page__intro-content">
|
||||
<HeaderTitle
|
||||
placeholder="About Us"
|
||||
secondary="درباره"
|
||||
primary="شیرینی بلوط"
|
||||
size="lg"
|
||||
align="start"
|
||||
/>
|
||||
<p className="about-page__description">
|
||||
شیرینیسرای بلوط از سال ۱۳۷۸ در قم فعالیت خود را آغاز کرده و با بیش از دو دهه تجربه، به
|
||||
عنوان نامی قابل اعتماد در تولید کیک و شیرینی شناخته میشود. این مجموعه با ترکیب مهارت،
|
||||
خلاقیت و مواد اولیه باکیفیت، انواع کیکهای ساده تا عروسی و سفارشی را با دقت و تازگی بالا
|
||||
ارائه میدهد و رضایت مشتری را اولویت اصلی خود میداند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-container stat-section">
|
||||
{STATS.map((stat, index) => (
|
||||
<div key={stat.label} className="stat-section__group">
|
||||
{index > 0 && <div className="stat-section__divider" aria-hidden="true" />}
|
||||
<div className="stat-section__item">
|
||||
<p className="stat-section__item--number">{stat.number}</p>
|
||||
<p className="stat-section__item--label">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<HeaderTitle
|
||||
placeholder="For The Best"
|
||||
secondary="چرا باید"
|
||||
primary="ما را انتخاب کنید"
|
||||
size="lg"
|
||||
align="center"
|
||||
className="about-page__why-title"
|
||||
/>
|
||||
|
||||
<div className="max-container feature-section">
|
||||
<div className="feature-section__col">
|
||||
{FEATURES_RIGHT.map((feature, index) => (
|
||||
<div key={feature.primary}>
|
||||
<div className="feature-section__item">
|
||||
<img src={feature.icon} alt="" className="feature-section__icon" width={70} />
|
||||
<div className="feature-section__title">
|
||||
<p className="feature-section__title--secondary">{feature.secondary}</p>
|
||||
<p className="feature-section__title--primary">{feature.primary}</p>
|
||||
</div>
|
||||
</div>
|
||||
{index === 0 && <hr className="feature-section__hr" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="feature-section__cake">
|
||||
<img src={strawberryCake} alt="کیک توتفرنگی" />
|
||||
</div>
|
||||
|
||||
<div className="feature-section__col">
|
||||
{FEATURES_LEFT.map((feature, index) => (
|
||||
<div key={feature.primary}>
|
||||
<div className="feature-section__item feature-section__item--align">
|
||||
<img src={feature.icon} alt="" className="feature-section__icon" width={70} />
|
||||
<div className="feature-section__title">
|
||||
<p className="feature-section__title--secondary">{feature.secondary}</p>
|
||||
<p className="feature-section__title--primary">{feature.primary}</p>
|
||||
</div>
|
||||
</div>
|
||||
{index === 0 && <hr className="feature-section__hr" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OffersHours className="about-page__footer-block" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
.contact-page__body {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.contact-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.contact-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.contact-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.contact-field span {
|
||||
font-size: 12px;
|
||||
color: var(--color-secondary);
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.contact-field input,
|
||||
.contact-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;
|
||||
}
|
||||
|
||||
.contact-field input:focus,
|
||||
.contact-field textarea:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.contact-field--textarea textarea {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.contact-form__submit {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.contact-info__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.contact-info__icon {
|
||||
width: clamp(28px, 4vw, 45px);
|
||||
height: clamp(28px, 4vw, 45px);
|
||||
color: var(--color-gray-100);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-info__title {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.contact-info__title--primary {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(12px, 3vw, 20px);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.contact-info__title--secondary {
|
||||
font-size: clamp(12px, 3vw, 19px);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.contact-info__title--secondary a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.contact-info__title--span {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(8px, 2vw, 10px);
|
||||
}
|
||||
|
||||
.contact-page__map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 50px;
|
||||
margin-top: 50px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.contact-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.contact-info__item {
|
||||
flex: 1 1 200px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.contact-form__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.contact-page__map {
|
||||
height: 260px;
|
||||
border-radius: 24px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.contact-info__item {
|
||||
flex: none;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.contact-form__submit .btn-primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ChevronLeft, Instagram, Phone } from 'lucide-react'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import OffersHours from '../components/OffersHours'
|
||||
import chatIcon from '../assets/images/ChatTeardropDots.svg'
|
||||
import './ContactUsPage.css'
|
||||
|
||||
const MAPS = [
|
||||
'https://www.openstreetmap.org/export/embed.html?bbox=50.88814616203309%2C34.649502368693994%2C50.901943445205696%2C34.65515083431795&layer=mapnik&marker=34.652326649618004%2C50.895044803619385',
|
||||
'https://www.openstreetmap.org/export/embed.html?bbox=50.7892370223999%2C34.58538297329978%2C50.86090564727784%2C34.61848189648619&layer=mapnik&marker=34.60193408382794%2C50.82507133483887',
|
||||
]
|
||||
|
||||
const CONTACT_INFO = [
|
||||
{
|
||||
title: 'شماره تماس (شعبه یک)',
|
||||
value: '۳۷۷۲۸۱۸۱ (۰۲۵)',
|
||||
icon: 'phone' as const,
|
||||
},
|
||||
{
|
||||
title: 'شماره تماس (شعبه دو)',
|
||||
value: '۳۲۸۵۸۱۸۱ (۰۲۵)',
|
||||
icon: 'phone' as const,
|
||||
},
|
||||
{
|
||||
title: 'اینستاگرام',
|
||||
value: 'shirini_balout',
|
||||
prefix: 'instagram /',
|
||||
icon: 'instagram' as const,
|
||||
href: 'https://instagram.com/shirini_balout',
|
||||
},
|
||||
]
|
||||
|
||||
export default function ContactUsPage() {
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
subject: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'شیرینی بلوط - صفحه تماس با ما'
|
||||
}, [])
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contact-page">
|
||||
<PageHeader
|
||||
icon={chatIcon}
|
||||
placeholder="Contact Us"
|
||||
secondary="با ما"
|
||||
primary="در ارتباط باشید"
|
||||
crumbs={[
|
||||
{ label: 'صفحه اصلی', to: '/' },
|
||||
{ label: 'تماس با ما' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="max-container contact-page__body">
|
||||
<div className="contact-page__grid">
|
||||
<form className="contact-form" onSubmit={handleSubmit}>
|
||||
<div className="contact-form__row">
|
||||
<label className="contact-field">
|
||||
<span>نام</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="contact-field">
|
||||
<span>نام خانوادگی</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contact-form__row">
|
||||
<label className="contact-field">
|
||||
<span>موضوع</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm((f) => ({ ...f, subject: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="contact-field">
|
||||
<span>شماره همراه</span>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="contact-field contact-field--textarea">
|
||||
<span>متن پیام</span>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="contact-form__submit">
|
||||
<button type="submit" className="btn-primary">
|
||||
مشاهده محصولات
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="contact-info">
|
||||
{CONTACT_INFO.map((item) => (
|
||||
<div key={item.title} className="contact-info__item">
|
||||
{item.icon === 'phone' ? (
|
||||
<Phone className="contact-info__icon" strokeWidth={1.25} />
|
||||
) : (
|
||||
<Instagram className="contact-info__icon" strokeWidth={1.25} />
|
||||
)}
|
||||
<div className="contact-info__title">
|
||||
<p className="contact-info__title--primary">{item.title}</p>
|
||||
<p className="contact-info__title--secondary">
|
||||
{item.prefix && (
|
||||
<span className="contact-info__title--span">{item.prefix} </span>
|
||||
)}
|
||||
{item.href ? (
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||
{item.value}
|
||||
</a>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{MAPS.map((src) => (
|
||||
<iframe
|
||||
key={src}
|
||||
className="contact-page__map"
|
||||
title="نقشه شعبه"
|
||||
src={src}
|
||||
width="100%"
|
||||
height="500"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<OffersHours showOffers={false} className="offers-hours--compact" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
.home__hero-title {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.home__best-title {
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
/* Carousel / hero cake */
|
||||
.carousel-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(280px, 640px) 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-layout__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.carousel-layout__side--right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.carousel-layout__side--left {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.carousel-layout__img--right {
|
||||
height: 340px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin-right: -40px;
|
||||
}
|
||||
|
||||
.carousel-layout__img--left {
|
||||
height: 360px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin-left: -40px;
|
||||
}
|
||||
|
||||
.carousel-layout__center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.carousel-layout__cake {
|
||||
width: 100%;
|
||||
max-width: 590px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Decor cards */
|
||||
.home-decor {
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
.home-decor__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-decor__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-decor__card--link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.home-decor__card--link:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.home-decor__card-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-decor__card img {
|
||||
width: 250px;
|
||||
max-width: 45%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.home-decor__cards--title {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(18px, 3vw, 25px);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.home-decor__cards--subtitle {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(18px, 4vw, 35px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* About */
|
||||
.about-section {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
.about-section__image {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.about-section__image img {
|
||||
max-height: 500px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.about-section__description {
|
||||
margin-top: 24px;
|
||||
font-size: clamp(13px, 3vw, 16px);
|
||||
line-height: clamp(20px, 6vw, 25px);
|
||||
text-align: justify;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Sohan */
|
||||
.sohan-section {
|
||||
margin-top: 200px;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sohan-section__peste {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 200px;
|
||||
width: min(160px, 18vw);
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sohan-section__inner {
|
||||
max-width: 1100px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 36px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sohan-section__top {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sohan-section__media {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 466px;
|
||||
max-width: min(466px, 48vw);
|
||||
z-index: 2;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sohan-section__image {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: -10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Pattern sticks to the sohan tin (extends behind/ beside it toward the outer edge) */
|
||||
.sohan-section__pattern {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
/* Physical right side of the tin — matches live: tin overlaps left of pattern */
|
||||
left: 42%;
|
||||
width: 116%;
|
||||
max-width: 543px;
|
||||
height: auto;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sohan-section__title {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sohan-section__text {
|
||||
font-size: clamp(13px, 3vw, 16px);
|
||||
line-height: 1.8;
|
||||
text-align: justify;
|
||||
color: var(--color-secondary);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Offers wrapper */
|
||||
.home-footer-block {
|
||||
margin-top: 300px;
|
||||
}
|
||||
|
||||
.offers-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-image: url('../assets/images/gradiantCircle.png');
|
||||
background-position: center top;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.offers-wrapper__img {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.offers-wrapper__img img {
|
||||
width: 400px;
|
||||
max-width: 70vw;
|
||||
}
|
||||
|
||||
.offers-wrapper__title {
|
||||
padding-top: 150px;
|
||||
}
|
||||
|
||||
.offers {
|
||||
margin: 20px auto;
|
||||
max-width: 900px;
|
||||
padding: 0 50px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.offers__item + .offers__item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.offers__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(94, 94, 94, 0.2);
|
||||
padding: 14px 0;
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(16px, 3vw, 22px);
|
||||
font-weight: 300;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.offers__trigger--open {
|
||||
color: var(--color-secondary);
|
||||
border-bottom: none;
|
||||
font-size: clamp(18px, 4vw, 25px);
|
||||
font-weight: 400;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.offers__body {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(143, 65, 12, 0.12);
|
||||
}
|
||||
|
||||
.offers__description {
|
||||
font-size: clamp(13px, 3vw, 16px);
|
||||
text-align: justify;
|
||||
line-height: 1.9;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.offers__cta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.semicolon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 40px 0 20px;
|
||||
}
|
||||
|
||||
.semicolon img {
|
||||
max-width: 24px;
|
||||
width: 5vw;
|
||||
}
|
||||
|
||||
.hours {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(14px, 3vw, 19px);
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hours__number {
|
||||
background: linear-gradient(to right, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: clamp(32px, 4vw, 64px);
|
||||
margin-inline: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.sohan-section__peste {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.carousel-layout__side {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carousel-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home__best-title {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.home-decor {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 64px;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-section__image {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sohan-section {
|
||||
margin-top: 120px;
|
||||
}
|
||||
|
||||
.sohan-section__top {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sohan-section__media {
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.sohan-section__pattern {
|
||||
left: 38%;
|
||||
width: 110%;
|
||||
}
|
||||
|
||||
.sohan-section__title .header-title__placeholder {
|
||||
font-size: clamp(44px, 10vw, 72px);
|
||||
}
|
||||
|
||||
.sohan-section__title.text-start.texts--overlap .header-title--top {
|
||||
top: 8%;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.home-footer-block {
|
||||
margin-top: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home__hero-title {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.home-decor {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.home-decor__row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.home-decor__card {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.home-decor__card img {
|
||||
max-width: 100%;
|
||||
width: min(120px, 38vw);
|
||||
}
|
||||
|
||||
.home-decor__cards--title {
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.home-decor__cards--subtitle {
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.carousel-layout__cake {
|
||||
max-width: min(320px, 82vw);
|
||||
}
|
||||
|
||||
.about-section {
|
||||
margin-top: 56px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.about-section__image img {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.about-section__description {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sohan-section {
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.sohan-section__inner {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.sohan-section__top {
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sohan-section__media {
|
||||
max-width: min(260px, 70vw);
|
||||
width: min(260px, 70vw);
|
||||
}
|
||||
|
||||
.sohan-section__pattern {
|
||||
opacity: 0.55;
|
||||
left: 36%;
|
||||
}
|
||||
|
||||
.sohan-section__title .header-title__placeholder {
|
||||
font-size: clamp(36px, 12vw, 56px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sohan-section__title.text-start.texts--overlap .header-title--top {
|
||||
top: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.offers {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.offers-wrapper__img img {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.offers-wrapper__title {
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.home-footer-block {
|
||||
margin-top: 140px;
|
||||
}
|
||||
|
||||
.hours__number {
|
||||
font-size: clamp(28px, 10vw, 40px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home-decor__row {
|
||||
gap: 14px 10px;
|
||||
}
|
||||
|
||||
.home-decor__card img {
|
||||
width: min(108px, 36vw);
|
||||
}
|
||||
|
||||
.home-decor__cards--title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.home-decor__cards--subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-footer-block {
|
||||
margin-top: 110px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import HeaderTitle from '../components/HeaderTitle'
|
||||
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'
|
||||
import deliciousDecorate from '../assets/images/delicious-decorate.png'
|
||||
import strawberryDecorate from '../assets/images/strawberry-decorate.png'
|
||||
import muffinsDecorate from '../assets/images/muffins-decorate.png'
|
||||
import tiramisuCake from '../assets/images/tiramisu-cake.png'
|
||||
import sohan from '../assets/images/sohan4.png'
|
||||
import sohanPattern from '../assets/images/backImage1.png'
|
||||
import cookies from '../assets/images/cookies.png'
|
||||
import flatLay from '../assets/images/FlatLay.png'
|
||||
import peste from '../assets/images/peste.png'
|
||||
import './HomePage.css'
|
||||
|
||||
const DECOR_CARDS = [
|
||||
{
|
||||
title: 'انواع',
|
||||
subtitle: 'کیـــــــــــک',
|
||||
image: deliciousDecorate,
|
||||
to: '/products/cake',
|
||||
},
|
||||
{
|
||||
title: 'انواع',
|
||||
subtitle: 'شیرینـــــی',
|
||||
image: strawberryDecorate,
|
||||
to: '/products/shirini',
|
||||
},
|
||||
{
|
||||
title: 'انواع',
|
||||
subtitle: 'نـــــــــــــان',
|
||||
image: muffinsDecorate,
|
||||
},
|
||||
]
|
||||
|
||||
const OFFER_CATEGORIES = [
|
||||
{
|
||||
title: 'شیرینی',
|
||||
description:
|
||||
'شیرینیهای بلوط همیشه تازه، خوشطعم و باکیفیت تهیه میشوند 🌰 از مدلهای ساده برای دورهمیهای خانوادگی تا شیرینیهای شیک و مجلسی، همه با بهترین مواد اولیه آماده میشوند تا کنار هر فنجان چای و هر جشن، لحظههایتان شیرینتر شود.',
|
||||
},
|
||||
{ title: 'كيكی جات' },
|
||||
{ title: 'شیرینی تر' },
|
||||
{ title: 'شیرینی خشک' },
|
||||
{ title: 'زولبیا و بامیه' },
|
||||
{ title: 'کیک شکلاتی' },
|
||||
{ title: 'کیک' },
|
||||
{ title: 'وسایل تم تولد' },
|
||||
{ title: 'سوهان' },
|
||||
{ title: 'آجیل و شکلات' },
|
||||
{ title: 'دسر ها' },
|
||||
{ title: 'شیرینی ها' },
|
||||
{ title: 'کیک های خامه ای' },
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const [openOffer, setOpenOffer] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'شیرینی بلوط - صفحه اصلی'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
{/* Hero title */}
|
||||
<HeaderTitle
|
||||
placeholder="Balout"
|
||||
primary="شیرینی بلوط"
|
||||
secondary="بلوط؛ شیرینیِ لحظههای کنار هم بودن"
|
||||
size="xl"
|
||||
align="center"
|
||||
primaryFirst
|
||||
className="home__hero-title"
|
||||
/>
|
||||
|
||||
{/* Carousel / cake with macarons */}
|
||||
<div className="carousel-layout">
|
||||
<div className="carousel-layout__side carousel-layout__side--right">
|
||||
<img src={macaronRight} alt="" className="carousel-layout__img--right" />
|
||||
</div>
|
||||
<div className="carousel-layout__center">
|
||||
<img src={chocolateCake} alt="کیک شکلاتی" className="carousel-layout__cake" />
|
||||
</div>
|
||||
<div className="carousel-layout__side carousel-layout__side--left">
|
||||
<img src={macaronLeft} alt="" className="carousel-layout__img--left" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* For The Best */}
|
||||
<HeaderTitle
|
||||
placeholder="For The Best"
|
||||
secondary="بهترین ها"
|
||||
primary="را از مــــا بخـــــواهید"
|
||||
size="lg"
|
||||
align="center"
|
||||
className="home__best-title"
|
||||
/>
|
||||
|
||||
{/* Decor cards */}
|
||||
<div className="max-container home-decor">
|
||||
<div className="home-decor__row">
|
||||
{DECOR_CARDS.map((card) => {
|
||||
const content = (
|
||||
<>
|
||||
<div className="home-decor__card-text">
|
||||
<p className="home-decor__cards--title">{card.title}</p>
|
||||
<p className="home-decor__cards--subtitle">{card.subtitle}</p>
|
||||
</div>
|
||||
<img src={card.image} alt={card.subtitle} />
|
||||
</>
|
||||
)
|
||||
|
||||
return card.to ? (
|
||||
<Link
|
||||
key={card.subtitle}
|
||||
to={card.to}
|
||||
className="home-decor__card home-decor__card--link"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={card.subtitle} className="home-decor__card">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About */}
|
||||
<div className="max-container about-section">
|
||||
<div className="about-section__image">
|
||||
<img src={tiramisuCake} alt="تیرامیسو" />
|
||||
</div>
|
||||
<div className="about-section__content">
|
||||
<HeaderTitle
|
||||
placeholder="About Us"
|
||||
secondary="درباره"
|
||||
primary="شیرینی بلوط"
|
||||
size="lg"
|
||||
align="start"
|
||||
/>
|
||||
<p className="about-section__description">
|
||||
شیرینیسرای بلوط از سال ۱۳۷۸ در قم فعالیت خود را آغاز کرده و با بیش از دو دهه تجربه،
|
||||
به عنوان نامی قابل اعتماد در تولید کیک و شیرینی شناخته میشود. این مجموعه با ترکیب
|
||||
مهارت، خلاقیت و مواد اولیه باکیفیت، انواع کیکهای ساده تا عروسی و سفارشی را با دقت و
|
||||
تازگی بالا ارائه میدهد و رضایت مشتری را اولویت اصلی خود میداند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sohan */}
|
||||
<section className="sohan-section">
|
||||
<img src={peste} alt="" className="sohan-section__peste" />
|
||||
<div className="sohan-section__inner">
|
||||
<div className="sohan-section__top">
|
||||
<div className="sohan-section__media">
|
||||
<img src={sohan} alt="سوهان" className="sohan-section__image" />
|
||||
<img src={sohanPattern} alt="" className="sohan-section__pattern" />
|
||||
</div>
|
||||
<HeaderTitle
|
||||
placeholder="Sohan"
|
||||
secondary="سوهان"
|
||||
primary="لذیذ و تازه بلوط"
|
||||
size="lg"
|
||||
align="start"
|
||||
className="sohan-section__title"
|
||||
/>
|
||||
</div>
|
||||
<p className="sohan-section__text">
|
||||
سوهان یکی از اصیلترین سوغات قم است که در شیرینیسرای بلوط با استفاده از مواد اولیه
|
||||
باکیفیت و تازه تهیه میشود. عطر دلانگیز کره و زعفران در کنار بافتی لطیف و طعمی
|
||||
ماندگار، سوهان بلوط را به انتخابی مناسب برای پذیرایی و هدیه تبدیل کرده است.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Special offers + hours */}
|
||||
<div className="home-footer-block">
|
||||
<section className="offers-wrapper">
|
||||
<div className="offers-wrapper__img">
|
||||
<img src={cookies} alt="کوکی" />
|
||||
</div>
|
||||
|
||||
<HeaderTitle
|
||||
placeholder="Special Menu Offers"
|
||||
secondary="پیشنهاد ویژه"
|
||||
primary="مارو از دست ندید!"
|
||||
size="lg"
|
||||
align="center"
|
||||
className="offers-wrapper__title"
|
||||
/>
|
||||
|
||||
<div className="offers">
|
||||
{OFFER_CATEGORIES.map((cat, index) => {
|
||||
const isOpen = openOffer === index
|
||||
return (
|
||||
<div key={cat.title} className="offers__item">
|
||||
<button
|
||||
type="button"
|
||||
className={`offers__trigger${isOpen ? ' offers__trigger--open' : ''}`}
|
||||
onClick={() => setOpenOffer(isOpen ? -1 : index)}
|
||||
>
|
||||
<span className="star">✱</span>
|
||||
<span>{cat.title}</span>
|
||||
</button>
|
||||
{isOpen && cat.description && (
|
||||
<div className="offers__body">
|
||||
<p className="offers__description">{cat.description}</p>
|
||||
<div className="offers__cta">
|
||||
<Link to="/products" className="btn-primary">
|
||||
مشاهده محصولات
|
||||
<ChevronLeft size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="semicolon">
|
||||
<img src={flatLay} alt="" />
|
||||
</div>
|
||||
|
||||
<HeaderTitle
|
||||
placeholder="Working Hours"
|
||||
secondary="ساعت کاری"
|
||||
primary="کافه نان و شیرینی بلوط"
|
||||
size="lg"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="hours">
|
||||
<div>
|
||||
<span className="star">✱</span>
|
||||
شنبه تا پنجشنبه <span className="hours__number">۸</span> صبـــــــــح /{' '}
|
||||
<span className="hours__number">۱۲</span> شـــــــــــب
|
||||
</div>
|
||||
<div>
|
||||
<span className="star">✱</span>
|
||||
جمعه و تعطیلات <span className="hours__number">۹</span> صبـــــــــح /{' '}
|
||||
<span className="hours__number">۱۲</span> شـــــــــــب
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
.product-detail__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 360px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.product-detail__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.product-detail__back:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.product-detail__top {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.product-detail .page-header {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-detail .page-header__breadcrumbs {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.product-detail__state--error {
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.product-detail__spinner {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(143, 65, 12, 0.15);
|
||||
border-top-color: var(--color-primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.product-detail__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 5fr 7fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
margin-top: 64px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.product-images {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3.25fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.product-images__main img,
|
||||
.product-images__thumbs img {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.product-images__thumbs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.product-images__thumbs button {
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-images__thumbs button.is-active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.product--name {
|
||||
color: var(--color-gray-400);
|
||||
font-size: clamp(18px, 4vw, 28px);
|
||||
font-weight: 400;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.product-detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.product-detail__rating {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.product--price {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(17px, 4vw, 35px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product--currency-unit {
|
||||
color: var(--color-gray-400);
|
||||
font-size: clamp(12px, 4vw, 19px);
|
||||
}
|
||||
|
||||
.product-detail__block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.caption__title {
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(9px, 3vw, 18px);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.caption__title .caption__text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.caption__text {
|
||||
color: var(--color-secondary);
|
||||
font-size: clamp(8px, 3vw, 16px);
|
||||
line-height: 30px;
|
||||
text-align: justify;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-detail__cart-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.product-detail__cart {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.qty-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid rgba(143, 65, 12, 0.18);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.qty-stepper__btn {
|
||||
width: 40px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.qty-stepper__btn:hover {
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
}
|
||||
|
||||
.qty-stepper__input {
|
||||
width: 64px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-inline: 1px solid rgba(143, 65, 12, 0.12);
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.qty-stepper__input::-webkit-outer-spin-button,
|
||||
.qty-stepper__input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-detail__description {
|
||||
margin-top: 48px;
|
||||
margin-bottom: 24px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.12);
|
||||
}
|
||||
|
||||
.product-detail__description-body {
|
||||
color: var(--color-secondary);
|
||||
font-family: Doran, sans-serif !important;
|
||||
font-size: clamp(13px, 2.2vw, 16px);
|
||||
line-height: 1.9;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.product-detail__description-body,
|
||||
.product-detail__description-body :where(*) {
|
||||
font-family: Doran, sans-serif !important;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(p, ul, ol, h1, h2, h3, h4, h5, h6, div) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(h1, h2, h3, h4, h5, h6) {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(ul, ol) {
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(li) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.product-detail__description-body :where(b, strong) {
|
||||
color: var(--color-gray-400);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-detail__related {
|
||||
margin-top: 80px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.product-detail__related-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.product-detail__related-icon {
|
||||
width: 70px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.product-detail__related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-detail__related-empty {
|
||||
color: var(--color-secondary);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.product-detail__related-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.product-detail__grid {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 48px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-images {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-images__thumbs {
|
||||
order: 1;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.product-images__thumbs button {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-detail__related-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.product-detail__related-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-detail__related {
|
||||
margin-top: 48px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.product-detail__related-title {
|
||||
margin-bottom: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-detail .page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.product-detail .page-header__breadcrumbs {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-detail .page-header__breadcrumbs ol {
|
||||
justify-content: center;
|
||||
padding: 8px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.product-detail__top {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.product-detail__back {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.product-detail__meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.product-detail__cart-wrap {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-detail__cart-wrap .qty-stepper {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-detail__cart-wrap .qty-stepper__btn {
|
||||
width: 48px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.product-detail__cart-wrap .qty-stepper__input {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.product-detail__cart {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.caption__text {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.product-detail__description {
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, Minus, Plus, Star } from 'lucide-react'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import ProductOptionsModal from '../components/ProductOptionsModal'
|
||||
import { getProduct, listProducts } from '../lib/api'
|
||||
import { addToCart } from '../lib/cart'
|
||||
import type { Product, ProductOptionValue } from '../lib/types'
|
||||
import { formatProductPrice, productImageSrc } from '../lib/types'
|
||||
import handbagIcon from '../assets/images/Handbag.svg'
|
||||
import cakeIcon from '../assets/images/Cake.svg'
|
||||
import defaultPhoto from '../assets/images/DefaultPhoto.png'
|
||||
import './ProductDetailPage.css'
|
||||
|
||||
function stripHtml(value: string) {
|
||||
return value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { slugOrId = '', id = '' } = useParams()
|
||||
const productId = id || slugOrId
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const [related, setRelated] = useState<Product[]>([])
|
||||
const [activeImage, setActiveImage] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [optionsOpen, setOptionsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'شیرینی بلوط - صفحه جزئیات محصول'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setProduct(null)
|
||||
setRelated([])
|
||||
|
||||
getProduct(productId)
|
||||
.then((item) => {
|
||||
if (cancelled) return
|
||||
setProduct(item)
|
||||
const galleryUrls = [
|
||||
item.mainImageUrl,
|
||||
...(item.gallery ?? []).map((g) => g.url),
|
||||
].filter(Boolean) as string[]
|
||||
setActiveImage(galleryUrls[0] || defaultPhoto)
|
||||
setLoading(false)
|
||||
|
||||
listProducts({
|
||||
categoryId: item.categoryId,
|
||||
page: 1,
|
||||
pageSize: 8,
|
||||
})
|
||||
.then((relatedResult) => {
|
||||
if (cancelled) return
|
||||
setRelated(relatedResult.items.filter((p) => p.id !== item.id).slice(0, 4))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRelated([])
|
||||
})
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setError(err.message || 'محصول یافت نشد')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [productId])
|
||||
|
||||
const thumbs = useMemo(() => {
|
||||
if (!product) return []
|
||||
const urls = [
|
||||
product.mainImageUrl,
|
||||
...(product.gallery ?? []).map((g) => g.url),
|
||||
].filter(Boolean) as string[]
|
||||
if (urls.length === 0) return [defaultPhoto]
|
||||
const unique = [...new Set(urls)]
|
||||
if (unique.length === 1) return [unique[0], unique[0]]
|
||||
return unique.slice(0, 6)
|
||||
}, [product])
|
||||
|
||||
const price = product ? formatProductPrice(product) : null
|
||||
const abstractText = product?.intro ? stripHtml(product.intro) : ''
|
||||
const descriptionHtml = product?.description?.trim() || ''
|
||||
const isWeight = product?.sellUnit === 'kilo'
|
||||
const qtyStep = isWeight ? 0.1 : 1
|
||||
const qtyMin = isWeight ? 0.1 : 1
|
||||
|
||||
function bumpQuantity(delta: number) {
|
||||
setQuantity((current) => {
|
||||
const next = Number((current + delta).toFixed(3))
|
||||
return next < qtyMin ? qtyMin : next
|
||||
})
|
||||
}
|
||||
|
||||
function commitAdd(options: ProductOptionValue[] = []) {
|
||||
if (!product) return
|
||||
addToCart({ product, quantity, options })
|
||||
setOptionsOpen(false)
|
||||
}
|
||||
|
||||
function handleAddClick() {
|
||||
if (!product) return
|
||||
if ((product.options?.length ?? 0) > 0) {
|
||||
setOptionsOpen(true)
|
||||
return
|
||||
}
|
||||
commitAdd()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(product?.sellUnit === 'kilo' ? 0.5 : 1)
|
||||
setOptionsOpen(false)
|
||||
}, [product?.id, product?.sellUnit])
|
||||
|
||||
return (
|
||||
<div className="product-detail">
|
||||
<div className="max-container product-detail__top">
|
||||
<Link to="/products" className="product-detail__back">
|
||||
<ChevronRight size={16} strokeWidth={2} />
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<PageHeader
|
||||
icon={handbagIcon}
|
||||
placeholder="Product Details"
|
||||
secondary="جزئیات"
|
||||
primary="محصــــول ما"
|
||||
crumbs={[
|
||||
{ label: 'صفحه اصلی', to: '/' },
|
||||
{ label: 'محصولات', to: '/products' },
|
||||
{ label: 'جزئیات محصول' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="max-container">
|
||||
{loading && (
|
||||
<div className="product-detail__state">
|
||||
<div className="product-detail__spinner" />
|
||||
<p>در حال بارگذاری...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="product-detail__state product-detail__state--error">
|
||||
<p>{error}</p>
|
||||
<Link to="/products" className="btn-primary">
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && product && price && (
|
||||
<>
|
||||
<div className="product-detail__grid">
|
||||
<div className="product-images">
|
||||
<div className="product-images__main">
|
||||
<img src={activeImage || productImageSrc(product, defaultPhoto)} alt={product.nameFa} />
|
||||
</div>
|
||||
<div className="product-images__thumbs">
|
||||
{thumbs.map((url, index) => (
|
||||
<button
|
||||
key={`${url}-${index}`}
|
||||
type="button"
|
||||
className={activeImage === url ? 'is-active' : undefined}
|
||||
onClick={() => setActiveImage(url)}
|
||||
>
|
||||
<img src={url} alt="" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="product-detail__info">
|
||||
<div className="product--name">{product.nameFa}</div>
|
||||
|
||||
<div className="product-detail__meta">
|
||||
<div className="product-detail__rating" aria-label="امتیاز ۵ از ۵">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} size={18} fill="currentColor" strokeWidth={0} />
|
||||
))}
|
||||
</div>
|
||||
<div className="product--price">
|
||||
{price.amount}{' '}
|
||||
<span className="product--currency-unit">تومان</span>
|
||||
<span className="product--currency-unit"> / {price.unitLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{abstractText && (
|
||||
<div className="product-detail__block">
|
||||
<div className="caption__title">توضیحات :</div>
|
||||
<div className="caption__text">{abstractText}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="product-detail__block">
|
||||
<div className="caption__title">
|
||||
{' '}
|
||||
دسته بندی :{' '}
|
||||
<span className="caption__text">{product.category?.nameFa ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="product-detail__cart-wrap">
|
||||
<div className="qty-stepper" dir="ltr">
|
||||
<button
|
||||
type="button"
|
||||
className="qty-stepper__btn"
|
||||
aria-label="کم کردن"
|
||||
onClick={() => bumpQuantity(-qtyStep)}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<input
|
||||
className="qty-stepper__input"
|
||||
type="number"
|
||||
min={qtyMin}
|
||||
step={qtyStep}
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
if (!Number.isFinite(next)) return
|
||||
setQuantity(next < qtyMin ? qtyMin : next)
|
||||
}}
|
||||
aria-label="تعداد"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="qty-stepper__btn"
|
||||
aria-label="افزایش"
|
||||
onClick={() => bumpQuantity(qtyStep)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary product-detail__cart"
|
||||
onClick={handleAddClick}
|
||||
>
|
||||
افزودن به سبد خرید
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product && (
|
||||
<ProductOptionsModal
|
||||
open={optionsOpen}
|
||||
product={product}
|
||||
quantity={quantity}
|
||||
onClose={() => setOptionsOpen(false)}
|
||||
onConfirm={commitAdd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{descriptionHtml && (
|
||||
<section className="product-detail__description">
|
||||
<div className="caption__title">توضیحات محصول :</div>
|
||||
<div
|
||||
className="product-detail__description-body"
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="product-detail__related">
|
||||
<div className="product-detail__related-title">
|
||||
<img src={cakeIcon} alt="" className="product-detail__related-icon" />
|
||||
<div className="text-start texts--separated size-md">
|
||||
<div className="header-title">
|
||||
<p className="header-title__placeholder">Related Products</p>
|
||||
<div className="header-title--top">
|
||||
<p className="header-title__secondary">محصولات</p>
|
||||
<p className="header-title__primary">مشابــــــــــــــــــــه</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{related.length > 0 ? (
|
||||
<div className="product-detail__related-grid">
|
||||
{related.map((item) => (
|
||||
<ProductCard key={item.id} product={item} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="product-detail__related-empty">محصول مشابهی یافت نشد.</p>
|
||||
)}
|
||||
|
||||
<div className="product-detail__related-actions">
|
||||
<Link to="/products" className="btn-primary">
|
||||
مشاهده همه محصولات
|
||||
<ChevronLeft size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
.products-page__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.products-page__filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
color: var(--color-secondary);
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.products-page__filters {
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid var(--color-gray-100);
|
||||
border-radius: 20px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.products-page__filters-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.products-page__filters-head button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-secondary);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.products-page__filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.products-page__filters-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.products-page__filters-grid input,
|
||||
.products-page__filters-grid select {
|
||||
border: 1px solid var(--color-gray-100);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.products-page__filters-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.products-page__reset {
|
||||
border: 1px solid var(--color-gray-100);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.products-page__content {
|
||||
min-height: 320px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.products-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.products-page__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 280px;
|
||||
color: var(--color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.products-page__state--error {
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.products-page__hint {
|
||||
color: var(--color-gray-300);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.products-page__spinner {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(143, 65, 12, 0.15);
|
||||
border-top-color: var(--color-primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.products-page__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.products-page__pagination button {
|
||||
min-width: 70px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
background: transparent;
|
||||
color: rgba(94, 94, 94, 0.6);
|
||||
font-family: inherit;
|
||||
font-size: clamp(15px, 3vw, 20px);
|
||||
}
|
||||
|
||||
.products-page__pagination button.is-active {
|
||||
background: rgba(143, 65, 12, 0.05);
|
||||
color: var(--color-primary);
|
||||
font-size: clamp(20px, 3vw, 25px);
|
||||
}
|
||||
|
||||
.products-page__pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.products-page__page-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.products-page__ellipsis {
|
||||
color: rgba(94, 94, 94, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.products-page__grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.products-page__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.products-page__filters-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.products-page__toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.products-page__filters {
|
||||
padding: 14px 14px 16px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.products-page__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.products-page__filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.products-page__filters-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.products-page__filters-actions .btn-primary,
|
||||
.products-page__reset {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.products-page__content {
|
||||
min-height: 220px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.products-page__pagination {
|
||||
margin-top: 28px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.products-page__pagination button {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.products-page__pagination button.is-active {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.products-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 280px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Filter, X } from 'lucide-react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { listCategories, listProducts } from '../lib/api'
|
||||
import { isCuid } from '../lib/ids'
|
||||
import type { CategoryNode, Product } from '../lib/types'
|
||||
import handbagIcon from '../assets/images/Handbag.svg'
|
||||
import './ProductsListPage.css'
|
||||
|
||||
function flattenCategories(nodes: CategoryNode[], depth = 0): Array<CategoryNode & { depth: number }> {
|
||||
const result: Array<CategoryNode & { depth: number }> = []
|
||||
for (const node of nodes) {
|
||||
result.push({ ...node, depth })
|
||||
if (node.children?.length) {
|
||||
result.push(...flattenCategories(node.children, depth + 1))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
export default function ProductsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { slugOrId = '' } = useParams()
|
||||
const categorySlug = slugOrId && !isCuid(slugOrId) ? slugOrId : ''
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [categories, setCategories] = useState<CategoryNode[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
|
||||
const [draftQ, setDraftQ] = useState('')
|
||||
const [draftCategorySlug, setDraftCategorySlug] = useState('')
|
||||
const [draftMinPrice, setDraftMinPrice] = useState('')
|
||||
const [draftMaxPrice, setDraftMaxPrice] = useState('')
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [minPrice, setMinPrice] = useState<number | undefined>()
|
||||
const [maxPrice, setMaxPrice] = useState<number | undefined>()
|
||||
|
||||
const flatCategories = useMemo(() => flattenCategories(categories), [categories])
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'شیرینی بلوط - صفحه محصولات'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setDraftCategorySlug(categorySlug)
|
||||
setPage(1)
|
||||
}, [categorySlug])
|
||||
|
||||
useEffect(() => {
|
||||
listCategories()
|
||||
.then(setCategories)
|
||||
.catch(() => setCategories([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
listProducts({
|
||||
q: q || undefined,
|
||||
categorySlug: categorySlug || undefined,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setProducts(result.items)
|
||||
setTotal(result.total)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setProducts([])
|
||||
setTotal(0)
|
||||
setError(err.message || 'خطا در دریافت محصولات')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [q, categorySlug, minPrice, maxPrice, page])
|
||||
|
||||
function applyFilters() {
|
||||
setQ(draftQ.trim())
|
||||
setMinPrice(draftMinPrice ? Number(draftMinPrice) : undefined)
|
||||
setMaxPrice(draftMaxPrice ? Number(draftMaxPrice) : undefined)
|
||||
setPage(1)
|
||||
setFilterOpen(false)
|
||||
|
||||
if (draftCategorySlug) {
|
||||
navigate(`/products/${draftCategorySlug}`)
|
||||
} else {
|
||||
navigate('/products')
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setDraftQ('')
|
||||
setDraftCategorySlug('')
|
||||
setDraftMinPrice('')
|
||||
setDraftMaxPrice('')
|
||||
setQ('')
|
||||
setMinPrice(undefined)
|
||||
setMaxPrice(undefined)
|
||||
setPage(1)
|
||||
setFilterOpen(false)
|
||||
navigate('/products')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products-page">
|
||||
<PageHeader
|
||||
fluid
|
||||
icon={handbagIcon}
|
||||
placeholder="New Products"
|
||||
secondary="محصولات"
|
||||
primary="جدید شیرینی بلوط"
|
||||
crumbs={[
|
||||
{ label: 'صفحه اصلی', to: '/' },
|
||||
{ label: 'محصولات', to: categorySlug ? '/products' : undefined },
|
||||
...(categorySlug
|
||||
? [
|
||||
{
|
||||
label:
|
||||
flatCategories.find((cat) => cat.slug === categorySlug)?.nameFa ||
|
||||
categorySlug,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="max-container max-container--fluid products-page__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="products-page__filter-btn"
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
>
|
||||
<Filter size={16} strokeWidth={1.75} />
|
||||
فیلتر کردن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filterOpen && (
|
||||
<div className="max-container max-container--fluid products-page__filters">
|
||||
<div className="products-page__filters-head">
|
||||
<span>فیلتر محصولات</span>
|
||||
<button type="button" onClick={() => setFilterOpen(false)} aria-label="بستن">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="products-page__filters-grid">
|
||||
<label>
|
||||
<span>جستجو</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draftQ}
|
||||
onChange={(e) => setDraftQ(e.target.value)}
|
||||
placeholder="نام محصول"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>دستهبندی</span>
|
||||
<select
|
||||
value={draftCategorySlug}
|
||||
onChange={(e) => setDraftCategorySlug(e.target.value)}
|
||||
>
|
||||
<option value="">همه</option>
|
||||
{flatCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{'—'.repeat(cat.depth)} {cat.nameFa}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>حداقل قیمت</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draftMinPrice}
|
||||
onChange={(e) => setDraftMinPrice(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>حداکثر قیمت</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draftMaxPrice}
|
||||
onChange={(e) => setDraftMaxPrice(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="products-page__filters-actions">
|
||||
<button type="button" className="btn-primary" onClick={applyFilters}>
|
||||
اعمال فیلتر
|
||||
</button>
|
||||
<button type="button" className="products-page__reset" onClick={resetFilters}>
|
||||
حذف فیلتر
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-container max-container--fluid products-page__content">
|
||||
{loading && (
|
||||
<div className="products-page__state">
|
||||
<div className="products-page__spinner" />
|
||||
<p>در حال بارگذاری محصولات...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="products-page__state products-page__state--error">
|
||||
<p>{error}</p>
|
||||
<p className="products-page__hint">
|
||||
مطمئن شوید بکاند روی پورت 3100 در حال اجراست.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && products.length === 0 && (
|
||||
<div className="products-page__state">
|
||||
<p>محصولی یافت نشد.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && products.length > 0 && (
|
||||
<>
|
||||
<div className="products-page__grid">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="products-page__pagination">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((n) => n === 1 || n === totalPages || Math.abs(n - page) <= 2)
|
||||
.map((n, index, arr) => (
|
||||
<span key={n} className="products-page__page-wrap">
|
||||
{index > 0 && arr[index - 1] !== n - 1 && (
|
||||
<span className="products-page__ellipsis">…</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={n === page ? 'is-active' : undefined}
|
||||
onClick={() => setPage(n)}
|
||||
>
|
||||
{n.toLocaleString('fa-IR')}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { isCuid } from '../lib/ids'
|
||||
import ProductDetailPage from './ProductDetailPage'
|
||||
import ProductsListPage from './ProductsListPage'
|
||||
|
||||
/** `/products/:slugOrId` — category slug list, or product detail when cuid. */
|
||||
export default function ProductsSlugOrDetailPage() {
|
||||
const { slugOrId = '' } = useParams()
|
||||
if (isCuid(slugOrId)) {
|
||||
return <ProductDetailPage />
|
||||
}
|
||||
return <ProductsListPage />
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
.quick-info {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.quick-info__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.quick-info__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-info__icon {
|
||||
width: 115px;
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-info__breadcrumbs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-info__breadcrumbs ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 12px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
font-size: clamp(10px, 2vw, 14px);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.quick-info__breadcrumbs a {
|
||||
color: inherit;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.quick-info__breadcrumbs a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.quick-info__breadcrumbs-divider {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.quick-info__breadcrumbs-current {
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quick-info__content {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quick-info__socials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.quick-info__branches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.quick-info-item {
|
||||
border: 1px solid var(--color-gray-100);
|
||||
border-radius: 20px;
|
||||
padding: 0 30px 30px;
|
||||
}
|
||||
|
||||
.quick-info-item--social {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.quick-info-item h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-primary);
|
||||
margin: -12px -10px 12px 0;
|
||||
padding-inline: 10px;
|
||||
width: fit-content;
|
||||
font-size: clamp(14px, 1.2vw, 17.5px);
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.quick-info-item__icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-info-item a {
|
||||
font-size: 16px;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.quick-info-item h5 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
opacity: 0.5;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.quick-info-item p {
|
||||
margin: 0 0 20px;
|
||||
color: #000;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.quick-info-item__contact {
|
||||
color: #000 !important;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.quick-info-item__spaced {
|
||||
margin-top: 20px !important;
|
||||
}
|
||||
|
||||
.quick-info-item__spaced:last-of-type {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quick-info-item__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 8px;
|
||||
color: var(--color-primary) !important;
|
||||
font-size: 14px !important;
|
||||
padding: 5px 10px 5px 7px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-info-item__button:hover {
|
||||
background-color: rgba(143, 65, 12, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.quick-info__socials {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.quick-info__breadcrumbs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-info__header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quick-info__branches {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-info__socials {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-info__icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-info__title-row {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Globe, Instagram } from 'lucide-react'
|
||||
import HeaderTitle from '../components/HeaderTitle'
|
||||
import infoIcon from '../assets/images/Info.svg'
|
||||
import './QuickInfoPage.css'
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
title: 'پیج اینستاگرام قنادی بلوط',
|
||||
href: 'https://instagram.com/shirini_balout',
|
||||
label: 'shirini_balout',
|
||||
icon: 'instagram' as const,
|
||||
},
|
||||
{
|
||||
title: 'پیج اینستاگرام سوهان صالح نیا',
|
||||
href: 'https://instagram.com/sohansalehnia',
|
||||
label: 'sohansalehnia',
|
||||
icon: 'instagram' as const,
|
||||
},
|
||||
{
|
||||
title: 'پیج اینستاگرام اُک',
|
||||
href: 'https://instagram.com/oaktasty',
|
||||
label: 'oaktasty',
|
||||
icon: 'instagram' as const,
|
||||
},
|
||||
{
|
||||
title: 'پیج اینستاگرام کافه بلوط',
|
||||
href: 'https://instagram.com/cafe_balout',
|
||||
label: 'cafe_balout',
|
||||
icon: 'instagram' as const,
|
||||
},
|
||||
{
|
||||
title: 'سایت سوهان صالح نیا',
|
||||
href: 'https://sohansalehnia.com/',
|
||||
label: 'Sohansalehnia.com',
|
||||
icon: 'globe' as const,
|
||||
},
|
||||
]
|
||||
|
||||
const BRANCHES = [
|
||||
{
|
||||
title: 'شعبه مرکزی',
|
||||
address: 'قم،باجک یک، نبش کوچه ۴۴',
|
||||
phone: '٣٧٧٢٨١٨١-(٠٢٥)',
|
||||
phoneHref: 'tel:02537728181',
|
||||
social: '٠٩١٩٩٧٠١١٦١',
|
||||
socialHref: 'tel:09199701161',
|
||||
mapHref: 'https://balad.ir/p/74BfuVy0ARg7rd',
|
||||
},
|
||||
{
|
||||
title: 'شعبه غدیر',
|
||||
address: 'بلوار غدیر،رو به روی دانشگاه قم',
|
||||
phone: '۳۲۸۵۸۱۸۱-(٠٢٥)',
|
||||
phoneHref: 'tel:02532858181',
|
||||
social: '۰۹۳۹۶۳۴۲۴۲۴',
|
||||
socialHref: 'tel:09396342424',
|
||||
mapHref: 'https://balad.ir/p/2e7GW3RbAgSDL7',
|
||||
},
|
||||
{
|
||||
title: 'شعبه هپی سنتر',
|
||||
address: 'بلوار غدیر،نبش کوچه ۱۹،همکف مجتمع هپی سنتر(مجموعه اُک)',
|
||||
phone: '۳۲۸۵۹۱۹۱-(٠٢٥)',
|
||||
phoneHref: 'tel:02532859191',
|
||||
social: '۰۹۱۲۰۶۹۹۲۲۰',
|
||||
socialHref: 'tel:09120699220',
|
||||
mapHref: 'https://balad.ir/p/6hUczGps3bZcrE',
|
||||
},
|
||||
]
|
||||
|
||||
export default function QuickInfoPage() {
|
||||
useEffect(() => {
|
||||
document.title = 'شیرینی بلوط - صفحه اطلاعات مجموعه'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="quick-info">
|
||||
<div className="max-container">
|
||||
<div className="quick-info__header">
|
||||
<div className="quick-info__title-row">
|
||||
<img src={infoIcon} alt="" className="quick-info__icon" width={115} height={128} />
|
||||
<HeaderTitle
|
||||
placeholder="Quick Info"
|
||||
secondary="اطلاعات"
|
||||
primary="مجموعه بلوط"
|
||||
size="md"
|
||||
align="separated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="quick-info__breadcrumbs" aria-label="مسیر صفحه">
|
||||
<ol>
|
||||
<li>
|
||||
<Link to="/">صفحه اصلی</Link>
|
||||
</li>
|
||||
<li className="quick-info__breadcrumbs-divider" aria-hidden="true">
|
||||
/
|
||||
</li>
|
||||
<li className="quick-info__breadcrumbs-current">اطلاعات مجموعه</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-container quick-info__content">
|
||||
<div className="quick-info__socials">
|
||||
{SOCIAL_LINKS.map((item) => (
|
||||
<div key={item.href} className="quick-info-item quick-info-item--social">
|
||||
<h3>
|
||||
{item.icon === 'instagram' ? (
|
||||
<Instagram size={18} strokeWidth={1.75} className="quick-info-item__icon" />
|
||||
) : (
|
||||
<Globe size={18} strokeWidth={1.75} className="quick-info-item__icon" />
|
||||
)}
|
||||
{item.title}
|
||||
</h3>
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
||||
{item.label}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="quick-info__branches">
|
||||
{BRANCHES.map((branch) => (
|
||||
<div key={branch.title} className="quick-info-item quick-info-item--branch">
|
||||
<h3>{branch.title}</h3>
|
||||
<h5>آدرس</h5>
|
||||
<p>{branch.address}</p>
|
||||
<h5>شماره تماس</h5>
|
||||
<a href={branch.phoneHref} className="quick-info-item__contact">
|
||||
{branch.phone}
|
||||
</a>
|
||||
<h5 className="quick-info-item__spaced">فضای مجازی</h5>
|
||||
<a href={branch.socialHref} className="quick-info-item__contact">
|
||||
{branch.social}
|
||||
</a>
|
||||
<h5 className="quick-info-item__spaced">آدرس شعبه روی نقشه</h5>
|
||||
<a
|
||||
href={branch.mapHref}
|
||||
className="quick-info-item__button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
برای مشاهده کلیک کنید
|
||||
<ChevronLeft size={14} />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
allowedHosts: [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'baloutpastry.com',
|
||||
'www.baloutpastry.com',
|
||||
],
|
||||
},
|
||||
})
|
||||