From b103e330ef646a4edcc2a454f3bde4aab2decd0a Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 5 Aug 2026 15:15:16 +0330 Subject: [PATCH] Wire dashboards to the API and add customer portal plus discounts. Co-authored-by: Cursor --- .env.example | 17 + CONTEXT.md | 3 +- package-lock.json | 31 + package.json | 1 + src/App.tsx | 215 ++++- src/components/AddressModal.module.css | 78 ++ src/components/AddressModal.tsx | 234 +++++ src/components/CategoryModal.module.css | 11 +- src/components/CategoryModal.tsx | 25 +- src/components/CategoryOptionsModal.tsx | 339 ++++--- src/components/ChangePasswordModal.tsx | 13 +- src/components/CreateOrderModal.module.css | 54 +- src/components/CreateOrderModal.tsx | 292 +++--- src/components/DiscountModal.module.css | 105 +++ src/components/DiscountModal.tsx | 469 ++++++++++ src/components/Header.module.css | 54 +- src/components/Header.tsx | 314 +++++-- src/components/OrderDetailsModal.module.css | 87 +- src/components/OrderDetailsModal.tsx | 120 ++- src/components/OrderLineItemModal.tsx | 17 +- src/components/OrderStatusModal.tsx | 6 +- src/components/PersianDateInput.module.css | 61 ++ src/components/PersianDateInput.tsx | 73 ++ src/components/ProductCard.module.css | 19 + src/components/ProductCard.tsx | 54 +- src/components/ProductOptionsModal.tsx | 149 +++- src/components/RichTextArea.module.css | 25 +- src/data/categories.ts | 23 +- src/data/orders.ts | 180 +--- src/data/products.ts | 138 +-- src/data/users.ts | 13 + src/index.css | 1 + src/lib/api.ts | 44 + src/lib/auth.ts | 93 +- src/lib/authApi.ts | 90 +- src/lib/branchesStore.ts | 55 -- src/lib/categoriesApi.ts | 82 ++ src/lib/discountsApi.ts | 115 +++ src/lib/flavorsApi.ts | 6 + src/lib/host.ts | 72 ++ src/lib/mediaApi.ts | 31 + src/lib/nav.ts | 61 ++ src/lib/orderSearch.ts | 109 +-- src/lib/ordersApi.ts | 73 ++ src/lib/productsApi.ts | 107 +++ src/lib/settingsApi.ts | 73 ++ src/lib/userAddressesStore.ts | 85 -- src/lib/usersApi.ts | 76 ++ src/pages/AddProductPage.module.css | 10 +- src/pages/AddProductPage.tsx | 611 ++++++++----- src/pages/CategoriesPage.tsx | 215 +++-- src/pages/CustomerAddressesPage.module.css | 215 +++++ src/pages/CustomerAddressesPage.tsx | 234 +++++ src/pages/CustomerDiscountsPage.tsx | 189 ++++ src/pages/CustomerHomePage.tsx | 36 + src/pages/CustomerOrdersPage.tsx | 247 +++++ src/pages/CustomerSectionPage.tsx | 58 ++ src/pages/DiscountsPage.module.css | 418 +++++++++ src/pages/DiscountsPage.tsx | 453 ++++++++++ src/pages/LoginPage.module.css | 83 +- src/pages/LoginPage.tsx | 942 ++++++++++++++++++-- src/pages/OrdersPage.module.css | 39 +- src/pages/OrdersPage.tsx | 174 +++- src/pages/ProductDetailsPage.module.css | 265 ++++++ src/pages/ProductDetailsPage.tsx | 222 +++++ src/pages/ProductsListPage.tsx | 201 ++++- src/pages/ProfilePage.module.css | 265 ++++++ src/pages/ProfilePage.tsx | 318 +++++++ src/pages/SettingsPage.module.css | 17 + src/pages/SettingsPage.tsx | 668 +++++++++----- src/pages/UsersPage.module.css | 18 +- src/pages/UsersPage.tsx | 24 +- vite.config.ts | 10 + 73 files changed, 8720 insertions(+), 1605 deletions(-) create mode 100644 src/components/AddressModal.module.css create mode 100644 src/components/AddressModal.tsx create mode 100644 src/components/DiscountModal.module.css create mode 100644 src/components/DiscountModal.tsx create mode 100644 src/components/PersianDateInput.module.css create mode 100644 src/components/PersianDateInput.tsx delete mode 100644 src/lib/branchesStore.ts create mode 100644 src/lib/categoriesApi.ts create mode 100644 src/lib/discountsApi.ts create mode 100644 src/lib/flavorsApi.ts create mode 100644 src/lib/host.ts create mode 100644 src/lib/mediaApi.ts create mode 100644 src/lib/ordersApi.ts create mode 100644 src/lib/productsApi.ts create mode 100644 src/lib/settingsApi.ts delete mode 100644 src/lib/userAddressesStore.ts create mode 100644 src/pages/CustomerAddressesPage.module.css create mode 100644 src/pages/CustomerAddressesPage.tsx create mode 100644 src/pages/CustomerDiscountsPage.tsx create mode 100644 src/pages/CustomerHomePage.tsx create mode 100644 src/pages/CustomerOrdersPage.tsx create mode 100644 src/pages/CustomerSectionPage.tsx create mode 100644 src/pages/DiscountsPage.module.css create mode 100644 src/pages/DiscountsPage.tsx create mode 100644 src/pages/ProductDetailsPage.module.css create mode 100644 src/pages/ProductDetailsPage.tsx create mode 100644 src/pages/ProfilePage.module.css create mode 100644 src/pages/ProfilePage.tsx diff --git a/.env.example b/.env.example index edcf870..fa76dc4 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,18 @@ +# Local DNS — add to /etc/hosts: +# 127.0.0.1 baloutpastry.com +# 127.0.0.1 admin.baloutpastry.com +# 127.0.0.1 customer.baloutpastry.com +# +# Dev URLs: +# Website: http://baloutpastry.com:5174 +# Login (only): http://customer.baloutpastry.com:5173/login +# Customer: http://customer.baloutpastry.com:5173 +# Admin panel: http://admin.baloutpastry.com:5173 (via «پنل ادمین» after login) +# Localhost: http://localhost:5173 (customer app / same login) + VITE_API_BASE_URL=http://localhost:3100/api/v1 +VITE_ADMIN_HOST=admin.baloutpastry.com +VITE_CUSTOMER_HOST=customer.baloutpastry.com +VITE_COOKIE_DOMAIN=.baloutpastry.com +VITE_WEBSITE_URL=http://baloutpastry.com:5174 +VITE_ALLOWED_RETURN_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174,http://localhost:5174,http://baloutpastry.com:5173,http://baloutpastry.com:5174,http://www.baloutpastry.com:5174 diff --git a/CONTEXT.md b/CONTEXT.md index 2fe4245..48db3b1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -131,5 +131,6 @@ Set `VITE_API_BASE_URL` to the real API URL **before** `npm run build` (it is ba ## Related -- Backend docs: clone `BaloutPastry/backend` and read its `README.md` +- Backend docs: clone `BaloutPastry/backend` and read `CONTEXT.md` +- Website (storefront): clone `BaloutPastry/website` and read `CONTEXT.md` (dev port **5174**) - Postgres for local API: Docker Compose on host port **5434** diff --git a/package-lock.json b/package-lock.json index de4d521..7e583b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-easy-crop": "^6.2.3", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.18.2" }, "devDependencies": { @@ -1206,6 +1207,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-date-object": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz", + "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==", + "license": "MIT" + }, "node_modules/react-dom": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", @@ -1231,6 +1238,30 @@ "react-dom": ">=16.4.0" } }, + "node_modules/react-element-popper": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz", + "integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-multi-date-picker": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", + "integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==", + "license": "MIT", + "dependencies": { + "react-date-object": "^2.1.8", + "react-element-popper": "^2.1.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", diff --git a/package.json b/package.json index 3e3602b..b72e76c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-easy-crop": "^6.2.3", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.18.2" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 84ff083..7698fb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,33 @@ +import { useEffect } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { LoginPage } from './pages/LoginPage' import { HomePage } from './pages/HomePage' +import { CustomerHomePage } from './pages/CustomerHomePage' import { ProductsPage } from './pages/ProductsPage' import { ProductsListPage } from './pages/ProductsListPage' +import { ProductDetailsPage } from './pages/ProductDetailsPage' import { CategoriesPage } from './pages/CategoriesPage' import { AddProductPage } from './pages/AddProductPage' import { UsersPage } from './pages/UsersPage' import { OrdersPage } from './pages/OrdersPage' import { SettingsPage } from './pages/SettingsPage' -import { isAuthenticated } from './lib/auth' +import { ProfilePage } from './pages/ProfilePage' +import { CustomerOrdersPage } from './pages/CustomerOrdersPage' +import { CustomerDiscountsPage } from './pages/CustomerDiscountsPage' +import { DiscountsPage } from './pages/DiscountsPage' +import { CustomerAddressesPage } from './pages/CustomerAddressesPage' +import { + getAuthUser, + isAuthenticated, + isElevatedRole, +} from './lib/auth' +import { + getAppKind, + isApexHost, + redirectApexToCustomer, + redirectToApp, + redirectToLogin, +} from './lib/host' function RequireAuth({ children }: { children: React.ReactNode }) { if (!isAuthenticated()) { @@ -17,6 +36,19 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return children } +function RequireAdmin({ children }: { children: React.ReactNode }) { + if (!isAuthenticated()) { + redirectToLogin() + return null + } + const user = getAuthUser() + if (!isElevatedRole(user?.role)) { + redirectToApp('customer', '/') + return null + } + return children +} + function RedirectIfAuth({ children }: { children: React.ReactNode }) { if (isAuthenticated()) { return @@ -24,7 +56,126 @@ function RedirectIfAuth({ children }: { children: React.ReactNode }) { return children } -export default function App() { +function AdminLoginRedirect() { + useEffect(() => { + redirectToLogin() + }, []) + return null +} + +function ApexRedirect() { + useEffect(() => { + redirectApexToCustomer() + }, []) + return null +} + +function AdminApp() { + return ( + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ) +} + +function CustomerApp() { return ( - + } /> - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - + } /> @@ -87,15 +206,23 @@ export default function App() { path="/orders" element={ - + } /> - + + + } + /> + + } /> @@ -103,3 +230,11 @@ export default function App() { ) } + +export default function App() { + if (isApexHost()) { + return + } + + return getAppKind() === 'admin' ? : +} diff --git a/src/components/AddressModal.module.css b/src/components/AddressModal.module.css new file mode 100644 index 0000000..34ab27c --- /dev/null +++ b/src/components/AddressModal.module.css @@ -0,0 +1,78 @@ +.modal { + max-width: 520px; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.colHalf { + grid-column: span 1; +} + +.colFull { + grid-column: 1 / -1; +} + +.textarea { + width: 100%; + min-height: 88px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(143, 65, 12, 0.14); + background: rgba(255, 250, 250, 0.88); + color: var(--text-primary); + font-family: inherit; + font-size: 0.92rem; + line-height: 1.6; + resize: vertical; + outline: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); +} + +.grid :global(select) { + width: 100%; + height: var(--field-height); + padding: 0 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(143, 65, 12, 0.14); + background: rgba(255, 250, 250, 0.88); + color: var(--text-primary); + font-family: inherit; + font-size: 0.92rem; + outline: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); + cursor: pointer; +} + +.grid :global(select:focus), +.textarea:focus { + border-color: var(--brown); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.grid :global(select:disabled), +.textarea:disabled { + opacity: 0.72; + cursor: not-allowed; +} + +.grid :global(input[dir='ltr']) { + text-align: right; + unicode-bidi: isolate; +} + +@media (max-width: 560px) { + .grid { + grid-template-columns: 1fr; + } + + .colHalf, + .colFull { + grid-column: auto; + } +} diff --git a/src/components/AddressModal.tsx b/src/components/AddressModal.tsx new file mode 100644 index 0000000..73545e1 --- /dev/null +++ b/src/components/AddressModal.tsx @@ -0,0 +1,234 @@ +import { useEffect, useId, useState } from 'react' +import { X } from 'lucide-react' +import { districts } from '../data/districts' +import { ApiError } from '../lib/api' +import type { UserAddress } from '../lib/usersApi' +import styles from './CategoryModal.module.css' +import localStyles from './AddressModal.module.css' + +export type AddressFormValues = { + name: string + district: string + address: string + landline: string +} + +type AddressModalProps = { + open: boolean + editing?: UserAddress | null + onClose: () => void + onSubmit: (values: AddressFormValues) => void | Promise +} + +const emptyForm: AddressFormValues = { + name: '', + district: '', + address: '', + landline: '', +} + +export function AddressModal({ + open, + editing = null, + onClose, + onSubmit, +}: AddressModalProps) { + const titleId = useId() + const isEditing = Boolean(editing) + const [form, setForm] = useState(emptyForm) + const [error, setError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + useEffect(() => { + if (!open) return + if (editing) { + setForm({ + name: editing.name, + district: editing.district, + address: editing.address, + landline: editing.landline ?? '', + }) + } else { + setForm(emptyForm) + } + setError('') + setIsSubmitting(false) + }, [open, editing]) + + useEffect(() => { + if (!open) return + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape' && !isSubmitting) onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, onClose, isSubmitting]) + + if (!open) return null + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (isSubmitting) return + + if (!form.name.trim()) { + setError('نام آدرس الزامی است') + return + } + if (!form.district) { + setError('انتخاب منطقه الزامی است') + return + } + if (!form.address.trim()) { + setError('آدرس کامل الزامی است') + return + } + + setError('') + setIsSubmitting(true) + try { + await onSubmit({ + name: form.name.trim(), + district: form.district, + address: form.address.trim(), + landline: form.landline.trim(), + }) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'ذخیره آدرس ناموفق بود.') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
event.stopPropagation()} + > +
+ +
+
+

Address

+

+ {isEditing ? 'ویرایش آدرس' : 'افزودن آدرس'} +

+
+ +
+ +
void handleSubmit(e)}> + {error && ( +
+ {error} +
+ )} + +
+
+ + + setForm((current) => ({ ...current, name: e.target.value })) + } + placeholder="مثلاً منزل" + disabled={isSubmitting} + autoFocus={!isEditing} + /> +
+ +
+ + +
+ +
+ +