Initial commit of Balout Pastry public website.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:01 +03:30
co-authored by Cursor
commit 35939e6ee4
80 changed files with 8796 additions and 0 deletions
+3
View File
@@ -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
+27
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+155
View File
@@ -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`
+11
View File
@@ -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
+13
View File
@@ -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>
+1341
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+33
View File
@@ -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>
)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -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

+13
View File
@@ -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

+13
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

+11
View File
@@ -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

+12
View File
@@ -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

+12
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

+568
View File
@@ -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;
}
}
+677
View File
@@ -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>
)
}
+113
View File
@@ -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;
}
}
+58
View File
@@ -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>
)
}
+315
View File
@@ -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;
}
}
+213
View File
@@ -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>
)
}
+52
View File
@@ -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>
)
}
+159
View File
@@ -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;
}
}
+113
View File
@@ -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>
)
}
+82
View File
@@ -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%;
}
}
+60
View File
@@ -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>
)
}
+99
View File
@@ -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;
}
}
+35
View File
@@ -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>
)
}
+180
View File
@@ -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;
}
}
+137
View File
@@ -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>
)
}
+78
View File
@@ -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;
}
}
+54
View File
@@ -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>
)
}
+294
View File
@@ -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;
}
+170
View File
@@ -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,
)
}
+153
View File
@@ -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
}
+174
View File
@@ -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)
}
}
+4
View File
@@ -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)
}
+85
View File
@@ -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
}
+13
View File
@@ -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>,
)
+238
View File
@@ -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;
}
}
+146
View File
@@ -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>
)
}
+167
View File
@@ -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;
}
}
+165
View File
@@ -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>
)
}
+537
View File
@@ -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;
}
}
+254
View File
@@ -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>
)
}
+415
View File
@@ -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;
}
}
+318
View File
@@ -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(/&nbsp;/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>
)
}
+243
View File
@@ -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;
}
}
+291
View File
@@ -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>
)
}
+13
View File
@@ -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 />
}
+189
View File
@@ -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%;
}
}
+154
View File
@@ -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>
)
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+18
View File
@@ -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',
],
},
})