mirror of
https://git.meshkee.com/BaloutPastry/dashboards.git
synced 2026-08-11 22:30:59 +04:30
Wire dashboards to the API and add customer portal plus discounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
af11d96fd0
commit
b103e330ef
@@ -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
|
||||
|
||||
+2
-1
@@ -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**
|
||||
|
||||
Generated
+31
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+175
-40
@@ -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 <Navigate to="/" replace />
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<AdminLoginRedirect />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<HomePage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ProductsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/list"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ProductsListPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/new"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AddProductPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/categories"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<CategoriesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/:productId/edit"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AddProductPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/:productId"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ProductDetailsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<UsersPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/orders"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<OrdersPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/discounts"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<DiscountsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<SettingsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ProfilePage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerApp() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -39,47 +190,15 @@ export default function App() {
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HomePage />
|
||||
<CustomerHomePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products"
|
||||
path="/profile"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ProductsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/list"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ProductsListPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AddProductPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/categories"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<CategoriesPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UsersPage />
|
||||
<ProfilePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -87,15 +206,23 @@ export default function App() {
|
||||
path="/orders"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<OrdersPage />
|
||||
<CustomerOrdersPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
path="/discounts"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
<CustomerDiscountsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/addresses"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<CustomerAddressesPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -103,3 +230,11 @@ export default function App() {
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
if (isApexHost()) {
|
||||
return <ApexRedirect />
|
||||
}
|
||||
|
||||
return getAppKind() === 'admin' ? <AdminApp /> : <CustomerApp />
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
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<AddressFormValues>(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 (
|
||||
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||
<div
|
||||
className={`${styles.modal} ${localStyles.modal}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.shine} aria-hidden />
|
||||
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.eyebrow}>Address</p>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{isEditing ? 'ویرایش آدرس' : 'افزودن آدرس'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={localStyles.grid}>
|
||||
<div className={`${styles.field} ${localStyles.colHalf}`}>
|
||||
<label htmlFor="address-name">نام آدرس</label>
|
||||
<input
|
||||
id="address-name"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((current) => ({ ...current, name: e.target.value }))
|
||||
}
|
||||
placeholder="مثلاً منزل"
|
||||
disabled={isSubmitting}
|
||||
autoFocus={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colHalf}`}>
|
||||
<label htmlFor="address-district">منطقه</label>
|
||||
<select
|
||||
id="address-district"
|
||||
value={form.district}
|
||||
onChange={(e) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
district: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">انتخاب منطقه</option>
|
||||
{districts.map((district) => (
|
||||
<option key={district} value={district}>
|
||||
{district}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colFull}`}>
|
||||
<label htmlFor="address-full">آدرس کامل</label>
|
||||
<textarea
|
||||
id="address-full"
|
||||
className={localStyles.textarea}
|
||||
rows={3}
|
||||
value={form.address}
|
||||
onChange={(e) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
address: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="خیابان، کوچه، پلاک، واحد"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colFull}`}>
|
||||
<label htmlFor="address-landline">تلفن ثابت (اختیاری)</label>
|
||||
<input
|
||||
id="address-landline"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
value={form.landline}
|
||||
onChange={(e) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
landline: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="025xxxxxxx"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'در حال ذخیره...' : isEditing ? 'ذخیره' : 'افزودن'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -173,12 +173,19 @@
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fffdfc;
|
||||
backdrop-filter: none;
|
||||
background: var(--dropdown-bg);
|
||||
backdrop-filter: blur(16px) saturate(1.15);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.15);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.14);
|
||||
}
|
||||
|
||||
.optionNested {
|
||||
margin-inline-start: calc(var(--nest-depth, 1) * 16px);
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 2px solid rgba(143, 65, 12, 0.18);
|
||||
}
|
||||
|
||||
.option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import {
|
||||
findCategory,
|
||||
@@ -204,7 +211,17 @@ export function CategoryModal({
|
||||
<li className={styles.emptyOption}>موردی یافت نشد</li>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<li key={option.id}>
|
||||
<li
|
||||
key={option.id}
|
||||
className={
|
||||
option.depth > 0 ? styles.optionNested : undefined
|
||||
}
|
||||
style={
|
||||
{
|
||||
'--nest-depth': option.depth,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${parentId === option.id ? styles.optionActive : ''}`}
|
||||
@@ -214,8 +231,8 @@ export function CategoryModal({
|
||||
setDropdownOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className={styles.optionFa}>{option.labelFa}</span>
|
||||
<span className={styles.optionEn}>{option.labelEn}</span>
|
||||
<span className={styles.optionFa}>{option.nameFa}</span>
|
||||
<span className={styles.optionEn}>{option.nameEn}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
|
||||
@@ -4,11 +4,13 @@ import type { Category } from '../data/categories'
|
||||
import {
|
||||
createFlavorBlock,
|
||||
createFlavorEntry,
|
||||
flavors,
|
||||
formatPrice,
|
||||
type CategoryFlavorOptions,
|
||||
type Flavor,
|
||||
type FlavorBlock,
|
||||
} from '../data/flavors'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listFlavors } from '../lib/flavorsApi'
|
||||
import {
|
||||
AmountPriceModal,
|
||||
type AmountPriceValues,
|
||||
@@ -21,7 +23,10 @@ type CategoryOptionsModalProps = {
|
||||
category: Category | null
|
||||
initialOptions?: CategoryFlavorOptions
|
||||
onClose: () => void
|
||||
onSave: (categoryId: string, options: CategoryFlavorOptions) => void
|
||||
onSave: (
|
||||
categoryId: string,
|
||||
options: CategoryFlavorOptions,
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
type EntryDraft = {
|
||||
@@ -40,13 +45,38 @@ export function CategoryOptionsModal({
|
||||
}: CategoryOptionsModalProps) {
|
||||
const titleId = useId()
|
||||
const [blocks, setBlocks] = useState<FlavorBlock[]>([])
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([])
|
||||
const [draft, setDraft] = useState<EntryDraft | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !category) return
|
||||
const cloned = structuredClone(initialOptions)
|
||||
setBlocks(cloned.length > 0 ? cloned : [createFlavorBlock()])
|
||||
|
||||
setError('')
|
||||
setDraft(null)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const flavorList = await listFlavors()
|
||||
setFlavors(flavorList)
|
||||
const defaultFlavorId = flavorList[0]?.id ?? ''
|
||||
const cloned = structuredClone(initialOptions)
|
||||
setBlocks(
|
||||
cloned.length > 0
|
||||
? cloned
|
||||
: [createFlavorBlock(defaultFlavorId)],
|
||||
)
|
||||
} catch (err) {
|
||||
setFlavors([])
|
||||
setBlocks([createFlavorBlock()])
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری طعمها ناموفق بود.',
|
||||
)
|
||||
}
|
||||
})()
|
||||
// Reset only when the dialog opens for a category
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, category?.id])
|
||||
@@ -55,12 +85,12 @@ export function CategoryOptionsModal({
|
||||
if (!open) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !draft) onClose()
|
||||
if (event.key === 'Escape' && !draft && !saving) onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, draft, onClose])
|
||||
}, [open, draft, saving, onClose])
|
||||
|
||||
if (!open || !category) return null
|
||||
|
||||
@@ -74,7 +104,10 @@ export function CategoryOptionsModal({
|
||||
}
|
||||
|
||||
function handleAddFlavor() {
|
||||
setBlocks((current) => [...current, createFlavorBlock()])
|
||||
setBlocks((current) => [
|
||||
...current,
|
||||
createFlavorBlock(flavors[0]?.id ?? ''),
|
||||
])
|
||||
}
|
||||
|
||||
function handleDuplicateFlavor(block: FlavorBlock) {
|
||||
@@ -90,7 +123,9 @@ export function CategoryOptionsModal({
|
||||
function handleRemoveFlavor(blockId: string) {
|
||||
setBlocks((current) => {
|
||||
const next = current.filter((block) => block.id !== blockId)
|
||||
return next.length > 0 ? next : [createFlavorBlock()]
|
||||
return next.length > 0
|
||||
? next
|
||||
: [createFlavorBlock(flavors[0]?.id ?? '')]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,8 +161,20 @@ export function CategoryOptionsModal({
|
||||
}))
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave(category.id, blocks)
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave(category.id, blocks)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'ذخیره آپشنها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -160,138 +207,160 @@ export function CategoryOptionsModal({
|
||||
className={styles.closeBtn}
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={optionStyles.body}>
|
||||
<ul className={optionStyles.flavorList}>
|
||||
{blocks.map((block) => (
|
||||
<li key={block.id} className={optionStyles.flavorBlock}>
|
||||
<div className={optionStyles.flavorTop}>
|
||||
<div className={styles.field}>
|
||||
<label
|
||||
className={optionStyles.srOnly}
|
||||
htmlFor={`category-flavor-${block.id}`}
|
||||
>
|
||||
طعم
|
||||
</label>
|
||||
<select
|
||||
id={`category-flavor-${block.id}`}
|
||||
className={optionStyles.select}
|
||||
value={block.flavorId}
|
||||
onChange={(e) =>
|
||||
updateBlock(block.id, (current) => ({
|
||||
...current,
|
||||
flavorId: e.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{flavors.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.nameFa} — {item.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className={optionStyles.empty} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{flavors.length === 0 ? (
|
||||
<p className={optionStyles.empty}>
|
||||
ابتدا طعمها را از طریق API ثبت کنید.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className={optionStyles.flavorList}>
|
||||
{blocks.map((block) => (
|
||||
<li key={block.id} className={optionStyles.flavorBlock}>
|
||||
<div className={optionStyles.flavorTop}>
|
||||
<div className={styles.field}>
|
||||
<label
|
||||
className={optionStyles.srOnly}
|
||||
htmlFor={`category-flavor-${block.id}`}
|
||||
>
|
||||
طعم
|
||||
</label>
|
||||
<select
|
||||
id={`category-flavor-${block.id}`}
|
||||
className={optionStyles.select}
|
||||
value={block.flavorId}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBlock(block.id, (current) => ({
|
||||
...current,
|
||||
flavorId: e.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{flavors.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.nameFa} — {item.nameEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={optionStyles.flavorActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.iconBtn}
|
||||
aria-label="تکثیر طعم"
|
||||
data-tooltip="تکثیر طعم"
|
||||
disabled={saving}
|
||||
onClick={() => handleDuplicateFlavor(block)}
|
||||
>
|
||||
<Copy size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||
aria-label="حذف طعم"
|
||||
data-tooltip="حذف طعم"
|
||||
disabled={saving}
|
||||
onClick={() => handleRemoveFlavor(block.id)}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={optionStyles.flavorActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.iconBtn}
|
||||
aria-label="تکثیر طعم"
|
||||
data-tooltip="تکثیر طعم"
|
||||
onClick={() => handleDuplicateFlavor(block)}
|
||||
>
|
||||
<Copy size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||
aria-label="حذف طعم"
|
||||
data-tooltip="حذف طعم"
|
||||
onClick={() => handleRemoveFlavor(block.id)}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className={optionStyles.list}>
|
||||
{block.entries.map((entry) => (
|
||||
<li key={entry.id} className={optionStyles.row}>
|
||||
<div className={optionStyles.rowText}>
|
||||
<span className={optionStyles.amount}>
|
||||
{entry.amount}
|
||||
</span>
|
||||
<span className={optionStyles.price}>
|
||||
{formatPrice(entry.price)} تومان
|
||||
</span>
|
||||
</div>
|
||||
<div className={optionStyles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
blockId: block.id,
|
||||
mode: 'edit',
|
||||
entryId: entry.id,
|
||||
values: {
|
||||
amount: entry.amount,
|
||||
price: String(entry.price),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
onClick={() =>
|
||||
handleRemoveEntry(block.id, entry.id)
|
||||
}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className={optionStyles.list}>
|
||||
{block.entries.map((entry) => (
|
||||
<li key={entry.id} className={optionStyles.row}>
|
||||
<div className={optionStyles.rowText}>
|
||||
<span className={optionStyles.amount}>
|
||||
{entry.amount}
|
||||
</span>
|
||||
<span className={optionStyles.price}>
|
||||
{formatPrice(entry.price)} تومان
|
||||
</span>
|
||||
</div>
|
||||
<div className={optionStyles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
blockId: block.id,
|
||||
mode: 'edit',
|
||||
entryId: entry.id,
|
||||
values: {
|
||||
amount: entry.amount,
|
||||
price: String(entry.price),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
handleRemoveEntry(block.id, entry.id)
|
||||
}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className={optionStyles.addAmountItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.addAmountCard}
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
blockId: block.id,
|
||||
mode: 'create',
|
||||
values: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus size={18} strokeWidth={1.75} />
|
||||
افزودن
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className={optionStyles.addAmountItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.addAmountCard}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
blockId: block.id,
|
||||
mode: 'create',
|
||||
values: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus size={18} strokeWidth={1.75} />
|
||||
افزودن
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.addOptionBtn}
|
||||
onClick={handleAddFlavor}
|
||||
>
|
||||
<Plus size={18} strokeWidth={1.75} />
|
||||
افزودن آپشن جدید
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.addOptionBtn}
|
||||
onClick={handleAddFlavor}
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus size={18} strokeWidth={1.75} />
|
||||
افزودن آپشن جدید
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
@@ -299,15 +368,17 @@ export function CategoryOptionsModal({
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || flavors.length === 0}
|
||||
>
|
||||
ذخیره آپشنها
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره آپشنها'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ type ChangePasswordModalProps = {
|
||||
open: boolean
|
||||
userName: string
|
||||
onClose: () => void
|
||||
onSubmit: (password: string) => void
|
||||
onSubmit: (password: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({
|
||||
@@ -41,7 +41,7 @@ export function ChangePasswordModal({
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!password.trim()) {
|
||||
setError('رمز عبور جدید الزامی است')
|
||||
@@ -52,7 +52,14 @@ export function ChangePasswordModal({
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(password)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit(password)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'تغییر رمز عبور ناموفق بود.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,7 +43,15 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lineRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px 10px;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -51,17 +59,57 @@
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lineOption {
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--brown);
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lineMeta {
|
||||
.lineCalc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.lineResult {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.lineFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed rgba(143, 65, 12, 0.18);
|
||||
}
|
||||
|
||||
.lineTotalLabel {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lineTotal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
|
||||
+185
-107
@@ -5,22 +5,23 @@ import {
|
||||
searchUsers,
|
||||
type SearchUserResult,
|
||||
} from '../lib/orderSearch'
|
||||
import { getBranches } from '../lib/branchesStore'
|
||||
import { listBranches, type Branch } from '../lib/settingsApi'
|
||||
import {
|
||||
addUserAddress,
|
||||
getUserAddresses,
|
||||
} from '../lib/userAddressesStore'
|
||||
createUserAddress,
|
||||
listUserAddresses,
|
||||
} from '../lib/usersApi'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { districts } from '../data/districts'
|
||||
import {
|
||||
formatOrderQuantityByUnit,
|
||||
getItemLineTotal,
|
||||
orderSellUnitLabel,
|
||||
type DeliveryType,
|
||||
type Order,
|
||||
type OrderDelivery,
|
||||
type OrderItem,
|
||||
type ShippingAddress,
|
||||
} from '../data/orders'
|
||||
import type { Product } from '../data/products'
|
||||
import type { CreateOrderItemPayload } from '../lib/ordersApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import {
|
||||
OrderLineItemModal,
|
||||
@@ -30,16 +31,18 @@ import styles from './CategoryModal.module.css'
|
||||
import localStyles from './CreateOrderModal.module.css'
|
||||
|
||||
export type CreateOrderValues = {
|
||||
customer: SearchUserResult
|
||||
items: OrderItem[]
|
||||
customerId: string
|
||||
deliveryType: DeliveryType
|
||||
branchId?: string
|
||||
shippingAddressId?: string
|
||||
note: string
|
||||
delivery: OrderDelivery
|
||||
items: CreateOrderItemPayload[]
|
||||
}
|
||||
|
||||
type CreateOrderModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: CreateOrderValues) => void
|
||||
onSubmit: (values: CreateOrderValues) => void | Promise<void>
|
||||
}
|
||||
|
||||
type SelectedLine = OrderLineDraft & { key: string }
|
||||
@@ -56,6 +59,22 @@ const emptyAddressForm = {
|
||||
landline: '',
|
||||
}
|
||||
|
||||
function toShippingAddress(row: {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
}): ShippingAddress {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
district: row.district,
|
||||
address: row.address,
|
||||
landline: row.landline ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateOrderModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -84,13 +103,13 @@ export function CreateOrderModal({
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [deliveryType, setDeliveryType] = useState<DeliveryType>('pickup')
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [branchId, setBranchId] = useState('')
|
||||
const [addresses, setAddresses] = useState<ShippingAddress[]>([])
|
||||
const [selectedAddressId, setSelectedAddressId] = useState('')
|
||||
const [addingAddress, setAddingAddress] = useState(false)
|
||||
const [addressForm, setAddressForm] = useState(emptyAddressForm)
|
||||
|
||||
const branches = getBranches()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -107,20 +126,50 @@ export function CreateOrderModal({
|
||||
setNote('')
|
||||
setError('')
|
||||
setDeliveryType('pickup')
|
||||
setBranchId(getBranches()[0]?.id ?? '')
|
||||
setBranchId('')
|
||||
setAddresses([])
|
||||
setSelectedAddressId('')
|
||||
setAddingAddress(false)
|
||||
setAddressForm(emptyAddressForm)
|
||||
setSaving(false)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await listBranches()
|
||||
setBranches(list)
|
||||
setBranchId(list[0]?.id ?? '')
|
||||
} catch (err) {
|
||||
setBranches([])
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری شعبهها ناموفق بود.',
|
||||
)
|
||||
}
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !customer) return
|
||||
const list = getUserAddresses(customer.id)
|
||||
setAddresses(list)
|
||||
setSelectedAddressId(list[0]?.id ?? '')
|
||||
setAddingAddress(false)
|
||||
setAddressForm(emptyAddressForm)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await listUserAddresses(customer.id)
|
||||
const mapped = list.map(toShippingAddress)
|
||||
setAddresses(mapped)
|
||||
setSelectedAddressId(mapped[0]?.id ?? '')
|
||||
setAddingAddress(false)
|
||||
setAddressForm(emptyAddressForm)
|
||||
} catch (err) {
|
||||
setAddresses([])
|
||||
setSelectedAddressId('')
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری آدرسها ناموفق بود.',
|
||||
)
|
||||
}
|
||||
})()
|
||||
}, [open, customer])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,19 +245,13 @@ export function CreateOrderModal({
|
||||
return sum + getItemLineTotal(item)
|
||||
}, 0)
|
||||
|
||||
function buildItems(): OrderItem[] {
|
||||
function buildItems(): CreateOrderItemPayload[] {
|
||||
return lines.map((line) => ({
|
||||
id: line.key,
|
||||
nameFa: line.product.nameFa,
|
||||
productId: line.product.id,
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.product.price,
|
||||
sellUnit: line.product.sellUnit,
|
||||
options:
|
||||
optionIds:
|
||||
line.options.length > 0
|
||||
? line.options.map((option) => ({
|
||||
name: option.name,
|
||||
price: option.price,
|
||||
}))
|
||||
? line.options.map((option) => option.id)
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
@@ -234,7 +277,7 @@ export function CreateOrderModal({
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
function handleAddAddress(event: React.FormEvent) {
|
||||
async function handleAddAddress(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!customer) return
|
||||
if (!addressForm.name.trim()) {
|
||||
@@ -250,20 +293,30 @@ export function CreateOrderModal({
|
||||
return
|
||||
}
|
||||
|
||||
const created = addUserAddress(customer.id, {
|
||||
name: addressForm.name.trim(),
|
||||
district: addressForm.district,
|
||||
address: addressForm.address.trim(),
|
||||
landline: addressForm.landline.trim(),
|
||||
})
|
||||
setAddresses(getUserAddresses(customer.id))
|
||||
setSelectedAddressId(created.id)
|
||||
setAddingAddress(false)
|
||||
setAddressForm(emptyAddressForm)
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await createUserAddress(customer.id, {
|
||||
name: addressForm.name.trim(),
|
||||
district: addressForm.district,
|
||||
address: addressForm.address.trim(),
|
||||
landline: addressForm.landline.trim() || undefined,
|
||||
})
|
||||
const mapped = toShippingAddress(created)
|
||||
setAddresses((current) => [...current, mapped])
|
||||
setSelectedAddressId(mapped.id)
|
||||
setAddingAddress(false)
|
||||
setAddressForm(emptyAddressForm)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'ذخیره آدرس ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFinalSubmit(event: React.FormEvent) {
|
||||
async function handleFinalSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!customer) {
|
||||
setError('انتخاب کاربر الزامی است')
|
||||
@@ -271,32 +324,34 @@ export function CreateOrderModal({
|
||||
}
|
||||
|
||||
if (deliveryType === 'pickup') {
|
||||
const branch = branches.find((item) => item.id === branchId)
|
||||
if (!branch) {
|
||||
if (!branchId) {
|
||||
setError('انتخاب شعبه الزامی است')
|
||||
return
|
||||
}
|
||||
onSubmit({
|
||||
customer,
|
||||
note: note.trim(),
|
||||
items: buildItems(),
|
||||
delivery: { type: 'pickup', branch: branch.name },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const selected = addresses.find((item) => item.id === selectedAddressId)
|
||||
if (!selected) {
|
||||
} else if (!selectedAddressId) {
|
||||
setError('انتخاب یا افزودن آدرس الزامی است')
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
customer,
|
||||
note: note.trim(),
|
||||
items: buildItems(),
|
||||
delivery: { type: 'shipping', shippingAddress: selected },
|
||||
})
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit({
|
||||
customerId: customer.id,
|
||||
deliveryType,
|
||||
branchId: deliveryType === 'pickup' ? branchId : undefined,
|
||||
shippingAddressId:
|
||||
deliveryType === 'shipping' ? selectedAddressId : undefined,
|
||||
note: note.trim(),
|
||||
items: buildItems(),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'ثبت سفارش ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -456,31 +511,64 @@ export function CreateOrderModal({
|
||||
{lines.length > 0 && (
|
||||
<ul className={localStyles.lines}>
|
||||
{lines.map((line) => {
|
||||
const optionsTotal = line.options.reduce(
|
||||
(sum, option) => sum + option.price,
|
||||
0,
|
||||
const unitLabel = orderSellUnitLabel[line.product.sellUnit]
|
||||
const productTotal = Math.round(
|
||||
line.quantity * line.product.price,
|
||||
)
|
||||
const optionRows = line.options.map((option) => ({
|
||||
...option,
|
||||
total: Math.round(line.quantity * option.price),
|
||||
}))
|
||||
const lineTotal =
|
||||
line.quantity * (line.product.price + optionsTotal)
|
||||
productTotal +
|
||||
optionRows.reduce((sum, option) => sum + option.total, 0)
|
||||
|
||||
return (
|
||||
<li key={line.key} className={localStyles.line}>
|
||||
<div className={localStyles.lineBody}>
|
||||
<span className={localStyles.lineName}>
|
||||
{line.product.nameFa}
|
||||
</span>
|
||||
{line.options.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className={localStyles.lineOption}
|
||||
>
|
||||
{option.name}
|
||||
<div className={localStyles.lineRow}>
|
||||
<span className={localStyles.lineName}>
|
||||
{line.product.nameFa}
|
||||
</span>
|
||||
<span className={localStyles.lineCalc} dir="rtl">
|
||||
{formatPrice(line.quantity)}{' '}
|
||||
<bdi>{unitLabel}</bdi>
|
||||
{' × '}
|
||||
{formatPrice(line.product.price)}
|
||||
</span>
|
||||
<span className={localStyles.lineResult} dir="rtl">
|
||||
= {formatPrice(productTotal)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{optionRows.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={localStyles.lineRow}
|
||||
>
|
||||
<span className={localStyles.lineOption}>
|
||||
{option.name}
|
||||
</span>
|
||||
<span className={localStyles.lineCalc} dir="rtl">
|
||||
{formatPrice(line.quantity)}{' '}
|
||||
<bdi>{unitLabel}</bdi>
|
||||
{' × '}
|
||||
{formatPrice(option.price)}
|
||||
</span>
|
||||
<span className={localStyles.lineResult} dir="rtl">
|
||||
= {formatPrice(option.total)} تومان
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className={localStyles.lineMeta}>
|
||||
{formatPrice(line.quantity)}{' '}
|
||||
{orderSellUnitLabel[line.product.sellUnit]} —{' '}
|
||||
{formatPrice(lineTotal)} تومان
|
||||
</span>
|
||||
|
||||
<div className={localStyles.lineFooter}>
|
||||
<span className={localStyles.lineTotalLabel}>
|
||||
جمع این قلم
|
||||
</span>
|
||||
<span className={localStyles.lineTotal} dir="ltr">
|
||||
{formatPrice(lineTotal)} تومان
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,7 +602,15 @@ export function CreateOrderModal({
|
||||
|
||||
{(itemCount > 0 || totalPrice > 0) && (
|
||||
<div className={localStyles.summary}>
|
||||
<span>جمع اقلام: {formatPrice(itemCount)}</span>
|
||||
<span>
|
||||
جمع اقلام:{' '}
|
||||
{formatOrderQuantityByUnit(
|
||||
lines.map((line) => ({
|
||||
quantity: line.quantity,
|
||||
sellUnit: line.product.sellUnit,
|
||||
})),
|
||||
).join(' · ') || formatPrice(itemCount)}
|
||||
</span>
|
||||
<span>{formatPrice(totalPrice)} تومان</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -533,7 +629,7 @@ export function CreateOrderModal({
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={handleFinalSubmit}>
|
||||
<form className={styles.form} onSubmit={(event) => void handleFinalSubmit(event)}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
@@ -751,9 +847,10 @@ export function CreateOrderModal({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={handleAddAddress}
|
||||
disabled={saving}
|
||||
onClick={(event) => void handleAddAddress(event)}
|
||||
>
|
||||
ذخیره آدرس
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره آدرس'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,6 +862,7 @@ export function CreateOrderModal({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setError('')
|
||||
setStep(1)
|
||||
@@ -772,8 +870,12 @@ export function CreateOrderModal({
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
<button type="submit" className={styles.submitBtn}>
|
||||
ثبت سفارش
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ثبت...' : 'ثبت سفارش'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -790,27 +892,3 @@ export function CreateOrderModal({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function buildOrderFromCreateValues(
|
||||
values: CreateOrderValues,
|
||||
nextId: string,
|
||||
): Order {
|
||||
const itemCount = values.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
const totalPrice = values.items.reduce(
|
||||
(sum, item) => sum + getItemLineTotal(item),
|
||||
0,
|
||||
)
|
||||
|
||||
return {
|
||||
id: nextId,
|
||||
createdAt: new Date().toISOString(),
|
||||
customerName: values.customer.name,
|
||||
customerPhone: values.customer.phone,
|
||||
itemCount,
|
||||
totalPrice,
|
||||
status: 'pending',
|
||||
delivery: values.delivery,
|
||||
items: values.items,
|
||||
note: values.note || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.modal {
|
||||
max-width: 720px;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.colCode {
|
||||
grid-column: span 8;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.colPercent {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.colFull {
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.colHalf {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.colHalf select,
|
||||
.colCode input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid 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 select: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);
|
||||
}
|
||||
|
||||
.selectedUser {
|
||||
margin-top: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.selectedRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.clearUser {
|
||||
font-size: 0.75rem;
|
||||
color: var(--brown);
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activeRow {
|
||||
grid-column: 1 / -1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.activeRow input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--brown);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.colCode,
|
||||
.colPercent,
|
||||
.colHalf {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import type { Category } from '../data/categories'
|
||||
import { formatCellNumber, stripUserTitle, type User } from '../data/users'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listCategories } from '../lib/categoriesApi'
|
||||
import type { Discount } from '../lib/discountsApi'
|
||||
import { listUsers } from '../lib/usersApi'
|
||||
import { PriceInput } from './PriceInput'
|
||||
import { PersianDateInput } from './PersianDateInput'
|
||||
import { parsePriceNumber, toEnglishDigits } from '../utils/price'
|
||||
import styles from './CategoryModal.module.css'
|
||||
import localStyles from './DiscountModal.module.css'
|
||||
|
||||
export type DiscountFormValues = {
|
||||
code: string
|
||||
userId: string | null
|
||||
categoryId: string | null
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type DiscountModalProps = {
|
||||
open: boolean
|
||||
editing?: Discount | null
|
||||
/** When set, new discounts are locked to this user and the picker is hidden. */
|
||||
lockedUser?: User | Discount['user'] | null
|
||||
onClose: () => void
|
||||
onSubmit: (values: DiscountFormValues) => void | Promise<void>
|
||||
}
|
||||
|
||||
type FlatCategory = { id: string; label: string }
|
||||
|
||||
function flattenCategories(
|
||||
nodes: Category[],
|
||||
prefix = '',
|
||||
): FlatCategory[] {
|
||||
const out: FlatCategory[] = []
|
||||
for (const node of nodes) {
|
||||
const label = prefix ? `${prefix} / ${node.nameFa}` : node.nameFa
|
||||
out.push({ id: node.id, label })
|
||||
if (node.children?.length) {
|
||||
out.push(...flattenCategories(node.children, label))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toDefaultExpiresIso(daysFromNow = 30) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + daysFromNow)
|
||||
date.setHours(23, 59, 59, 0)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
export function DiscountModal({
|
||||
open,
|
||||
editing = null,
|
||||
lockedUser = null,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: DiscountModalProps) {
|
||||
const titleId = useId()
|
||||
const isEditing = Boolean(editing)
|
||||
const userRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [code, setCode] = useState('')
|
||||
const [user, setUser] = useState<User | Discount['user'] | null>(null)
|
||||
const [userQuery, setUserQuery] = useState('')
|
||||
const [userOpen, setUserOpen] = useState(false)
|
||||
const [userResults, setUserResults] = useState<User[]>([])
|
||||
const [userLoading, setUserLoading] = useState(false)
|
||||
const [categories, setCategories] = useState<FlatCategory[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [minOrderDigits, setMinOrderDigits] = useState('0')
|
||||
const [expiresAt, setExpiresAt] = useState('')
|
||||
const [percent, setPercent] = useState('10')
|
||||
const [maxValueDigits, setMaxValueDigits] = useState('')
|
||||
const [active, setActive] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
void (async () => {
|
||||
try {
|
||||
const tree = await listCategories()
|
||||
setCategories(flattenCategories(tree))
|
||||
} catch {
|
||||
setCategories([])
|
||||
}
|
||||
})()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editing) {
|
||||
setCode(editing.code)
|
||||
setUser(editing.user)
|
||||
setUserQuery(
|
||||
editing.user
|
||||
? stripUserTitle(editing.user.name) || editing.user.name
|
||||
: '',
|
||||
)
|
||||
setCategoryId(editing.categoryId ?? '')
|
||||
setMinOrderDigits(String(editing.minOrderAmount))
|
||||
setExpiresAt(editing.expiresAt)
|
||||
setPercent(String(editing.percent))
|
||||
setMaxValueDigits(String(editing.maxValue))
|
||||
setActive(editing.active)
|
||||
} else {
|
||||
setCode('')
|
||||
setUser(lockedUser)
|
||||
setUserQuery(
|
||||
lockedUser
|
||||
? `${lockedUser.firstName} ${lockedUser.lastName}`.trim() ||
|
||||
('name' in lockedUser ? lockedUser.name : '')
|
||||
: '',
|
||||
)
|
||||
setCategoryId('')
|
||||
setMinOrderDigits('0')
|
||||
setExpiresAt(toDefaultExpiresIso(30))
|
||||
setPercent('10')
|
||||
setMaxValueDigits('')
|
||||
setActive(true)
|
||||
}
|
||||
setUserOpen(false)
|
||||
setUserResults([])
|
||||
setError('')
|
||||
setIsSubmitting(false)
|
||||
}, [open, editing, lockedUser])
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!userRef.current?.contains(event.target as Node)) {
|
||||
setUserOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !userOpen || lockedUser) return
|
||||
let cancelled = false
|
||||
setUserLoading(true)
|
||||
const timer = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await listUsers({
|
||||
q: userQuery.trim() || undefined,
|
||||
role: 'customer',
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
})
|
||||
if (!cancelled) setUserResults(result.items)
|
||||
} catch {
|
||||
if (!cancelled) setUserResults([])
|
||||
} finally {
|
||||
if (!cancelled) setUserLoading(false)
|
||||
}
|
||||
})()
|
||||
}, 250)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, userOpen, userQuery, lockedUser])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (isSubmitting) return
|
||||
|
||||
const normalizedCode = code.trim().toUpperCase()
|
||||
if (normalizedCode.length < 2) {
|
||||
setError('کد تخفیف حداقل ۲ کاراکتر باشد')
|
||||
return
|
||||
}
|
||||
if (!expiresAt) {
|
||||
setError('تاریخ انقضا الزامی است')
|
||||
return
|
||||
}
|
||||
const percentNum = Number(toEnglishDigits(percent).replace(/\D/g, ''))
|
||||
if (!Number.isFinite(percentNum) || percentNum < 1 || percentNum > 100) {
|
||||
setError('درصد تخفیف باید بین ۱ تا ۱۰۰ باشد')
|
||||
return
|
||||
}
|
||||
const maxValue = parsePriceNumber(maxValueDigits)
|
||||
if (maxValue === null || maxValue <= 0) {
|
||||
setError('سقف تخفیف باید بیشتر از صفر باشد')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit({
|
||||
code: normalizedCode,
|
||||
userId: lockedUser?.id ?? user?.id ?? null,
|
||||
categoryId: categoryId || null,
|
||||
minOrderAmount: parsePriceNumber(minOrderDigits) ?? 0,
|
||||
expiresAt,
|
||||
percent: percentNum,
|
||||
maxValue,
|
||||
active,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'ذخیره کد تخفیف ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||
<div
|
||||
className={`${styles.modal} ${localStyles.modal}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.shine} aria-hidden />
|
||||
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.eyebrow}>Discount</p>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{isEditing ? 'ویرایش کد تخفیف' : 'ثبت کد تخفیف'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={localStyles.grid}>
|
||||
<div className={`${styles.field} ${localStyles.colCode}`}>
|
||||
<label htmlFor="discount-code">کد تخفیف</label>
|
||||
<input
|
||||
id="discount-code"
|
||||
dir="ltr"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="WELCOME10"
|
||||
disabled={isSubmitting}
|
||||
autoFocus={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colPercent}`}>
|
||||
<label htmlFor="discount-percent">درصد تخفیف</label>
|
||||
<input
|
||||
id="discount-percent"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={percent}
|
||||
onChange={(e) => setPercent(e.target.value)}
|
||||
placeholder="۱۰"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colFull}`} ref={userRef}>
|
||||
<label htmlFor="discount-user">
|
||||
{lockedUser
|
||||
? 'کاربر'
|
||||
: 'کاربر (اختیاری — خالی = همه کاربران)'}
|
||||
</label>
|
||||
{lockedUser ? (
|
||||
<div className={localStyles.selectedRow}>
|
||||
<p className={localStyles.selectedUser}>
|
||||
{`${lockedUser.firstName} ${lockedUser.lastName}`.trim() ||
|
||||
('name' in lockedUser
|
||||
? stripUserTitle(lockedUser.name) || lockedUser.name
|
||||
: '')}
|
||||
</p>
|
||||
<p className={localStyles.selectedUser} dir="ltr">
|
||||
{formatCellNumber(lockedUser.cellNumber)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.searchWrap}>
|
||||
<Search size={16} className={styles.searchIcon} />
|
||||
<input
|
||||
id="discount-user"
|
||||
value={userQuery}
|
||||
onChange={(e) => {
|
||||
setUserQuery(e.target.value)
|
||||
setUser(null)
|
||||
setUserOpen(true)
|
||||
}}
|
||||
onFocus={() => setUserOpen(true)}
|
||||
placeholder="جستجو با نام یا موبایل — یا خالی بگذارید"
|
||||
disabled={isSubmitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ChevronDown size={16} className={styles.chevron} />
|
||||
</div>
|
||||
{user ? (
|
||||
<div className={localStyles.selectedRow}>
|
||||
<p className={localStyles.selectedUser} dir="ltr">
|
||||
{formatCellNumber(user.cellNumber)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={localStyles.clearUser}
|
||||
onClick={() => {
|
||||
setUser(null)
|
||||
setUserQuery('')
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
حذف کاربر
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className={localStyles.selectedUser}>عمومی — همه کاربران</p>
|
||||
)}
|
||||
{userOpen && (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{userLoading ? (
|
||||
<li className={styles.emptyOption}>در حال جستجو...</li>
|
||||
) : userResults.length === 0 ? (
|
||||
<li className={styles.emptyOption}>کاربری یافت نشد</li>
|
||||
) : (
|
||||
userResults.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.option}
|
||||
onClick={() => {
|
||||
setUser(item)
|
||||
setUserQuery(
|
||||
stripUserTitle(
|
||||
`${item.title} ${item.firstName} ${item.lastName}`,
|
||||
) || `${item.firstName} ${item.lastName}`,
|
||||
)
|
||||
setUserOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className={styles.optionFa}>
|
||||
{stripUserTitle(
|
||||
`${item.title} ${item.firstName} ${item.lastName}`,
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.optionEn} dir="ltr">
|
||||
{formatCellNumber(item.cellNumber)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colHalf}`}>
|
||||
<label htmlFor="discount-category">دستهبندی (اختیاری)</label>
|
||||
<select
|
||||
id="discount-category"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">همه دستهها</option>
|
||||
{categories.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colHalf}`}>
|
||||
<label htmlFor="discount-expires">تاریخ انقضا</label>
|
||||
<PersianDateInput
|
||||
id="discount-expires"
|
||||
value={expiresAt}
|
||||
onChange={setExpiresAt}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colHalf}`}>
|
||||
<label htmlFor="discount-min">حداقل مبلغ سفارش (تومان)</label>
|
||||
<PriceInput
|
||||
id="discount-min"
|
||||
value={minOrderDigits}
|
||||
onChange={setMinOrderDigits}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${localStyles.colHalf}`}>
|
||||
<label htmlFor="discount-max">سقف تخفیف (تومان)</label>
|
||||
<PriceInput
|
||||
id="discount-max"
|
||||
value={maxValueDigits}
|
||||
onChange={setMaxValueDigits}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className={localStyles.activeRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => setActive(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span>فعال</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,25 +103,35 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-start: 0;
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
padding: 8px;
|
||||
list-style: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.96);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
background: var(--dropdown-bg);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.12);
|
||||
box-shadow:
|
||||
0 14px 34px rgba(143, 65, 12, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.85);
|
||||
animation: fadeUp 0.2s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.dropdownLink {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
@@ -139,11 +149,41 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.userMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.userMenuTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.userMenuTrigger:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
}
|
||||
|
||||
.userDropdown {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
max-width: 120px;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -204,7 +244,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.userName {
|
||||
.userMenuTrigger .userName {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
+222
-92
@@ -1,32 +1,56 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, LogOut } from 'lucide-react'
|
||||
import { clearSession, getSession } from '../lib/auth'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronDown, Home, KeyRound, LayoutDashboard, LogOut, UserRound } from 'lucide-react'
|
||||
import { clearSession, getSession, isElevatedRole } from '../lib/auth'
|
||||
import { logout } from '../lib/authApi'
|
||||
import { navSections } from '../lib/nav'
|
||||
import { getAppKind, getWebsiteOrigin, redirectToApp, redirectToWebsite } from '../lib/host'
|
||||
import { adminHeaderSections, type NavSection } from '../lib/nav'
|
||||
import { updateUserPassword } from '../lib/usersApi'
|
||||
import { stripUserTitle } from '../data/users'
|
||||
import { ChangePasswordModal } from './ChangePasswordModal'
|
||||
import logo from '../images/Logo-balout-256.png'
|
||||
import styles from './Header.module.css'
|
||||
|
||||
type HeaderProps = {
|
||||
showLogout?: boolean
|
||||
sections?: NavSection[]
|
||||
variant?: 'admin' | 'customer'
|
||||
}
|
||||
|
||||
export function Header({ showLogout = true }: HeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
export function Header({
|
||||
showLogout = true,
|
||||
sections = adminHeaderSections,
|
||||
variant: _variant = 'admin',
|
||||
}: HeaderProps) {
|
||||
const session = getSession()
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
const navLabelId = useId()
|
||||
|
||||
const shortName = session
|
||||
? stripUserTitle(session.user.name) ||
|
||||
`${session.user.firstName} ${session.user.lastName}`.trim()
|
||||
: ''
|
||||
const showAdminDashboardLink =
|
||||
getAppKind() === 'customer' && isElevatedRole(session?.user.role)
|
||||
const showCustomerDashboardLink =
|
||||
getAppKind() === 'admin' && isElevatedRole(session?.user.role)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!navRef.current?.contains(event.target as Node)) {
|
||||
if (!headerRef.current?.contains(event.target as Node)) {
|
||||
setOpenId(null)
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpenId(null)
|
||||
if (event.key === 'Escape') {
|
||||
setOpenId(null)
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
@@ -43,104 +67,210 @@ export function Header({ showLogout = true }: HeaderProps) {
|
||||
} catch {
|
||||
clearSession()
|
||||
}
|
||||
navigate('/login')
|
||||
redirectToWebsite('/')
|
||||
}
|
||||
|
||||
function toggleSection(id: string) {
|
||||
setUserMenuOpen(false)
|
||||
setOpenId((current) => (current === id ? null : id))
|
||||
}
|
||||
|
||||
function toggleUserMenu() {
|
||||
setOpenId(null)
|
||||
setUserMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(password: string) {
|
||||
if (!session) return
|
||||
await updateUserPassword(session.user.id, password)
|
||||
setPasswordOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.inner}>
|
||||
<Link to="/" className={styles.brand} onClick={() => setOpenId(null)}>
|
||||
<img src={logo} alt="بلوط" className={styles.logo} />
|
||||
</Link>
|
||||
<>
|
||||
<header ref={headerRef} className={styles.header}>
|
||||
<div className={styles.inner}>
|
||||
<a
|
||||
href={getWebsiteOrigin() + '/'}
|
||||
className={styles.brand}
|
||||
onClick={() => {
|
||||
setOpenId(null)
|
||||
setUserMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<img src={logo} alt="بلوط" className={styles.logo} />
|
||||
</a>
|
||||
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={styles.nav}
|
||||
aria-labelledby={navLabelId}
|
||||
>
|
||||
<span id={navLabelId} className={styles.srOnly}>
|
||||
منوی اصلی
|
||||
</span>
|
||||
<ul className={styles.navList}>
|
||||
{navSections.map((section) => {
|
||||
const Icon = section.icon
|
||||
const isOpen = openId === section.id
|
||||
const hasDropdown = section.items.length > 0
|
||||
<nav className={styles.nav} aria-labelledby={navLabelId}>
|
||||
<span id={navLabelId} className={styles.srOnly}>
|
||||
منوی اصلی
|
||||
</span>
|
||||
<ul className={styles.navList}>
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon
|
||||
const isOpen = openId === section.id
|
||||
const hasDropdown = section.items.length > 0
|
||||
|
||||
return (
|
||||
<li key={section.id} className={styles.navItem}>
|
||||
{hasDropdown ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.navTrigger} ${isOpen ? styles.navTriggerOpen : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => toggleSection(section.id)}
|
||||
return (
|
||||
<li key={section.id} className={styles.navItem}>
|
||||
{hasDropdown ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.navTrigger} ${isOpen ? styles.navTriggerOpen : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => toggleSection(section.id)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} />
|
||||
<span>{section.title}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={1.75}
|
||||
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<ul className={styles.dropdown} role="menu">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href} role="none">
|
||||
<Link
|
||||
to={item.href}
|
||||
role="menuitem"
|
||||
className={styles.dropdownLink}
|
||||
onClick={() => setOpenId(null)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to={section.href}
|
||||
className={styles.navTrigger}
|
||||
onClick={() => {
|
||||
setOpenId(null)
|
||||
setUserMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} />
|
||||
<span>{section.title}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={1.75}
|
||||
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{isOpen && (
|
||||
<ul className={styles.dropdown} role="menu">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href} role="none">
|
||||
<Link
|
||||
to={item.href}
|
||||
role="menuitem"
|
||||
className={styles.dropdownLink}
|
||||
onClick={() => setOpenId(null)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to={section.href}
|
||||
className={styles.navTrigger}
|
||||
onClick={() => setOpenId(null)}
|
||||
{showLogout && (
|
||||
<div className={styles.actions}>
|
||||
{session && (
|
||||
<div className={styles.userMenu}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.userMenuTrigger} ${userMenuOpen ? styles.navTriggerOpen : ''}`}
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={toggleUserMenu}
|
||||
>
|
||||
<span className={styles.userName}>{shortName}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={1.75}
|
||||
className={`${styles.chevron} ${userMenuOpen ? styles.chevronOpen : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<ul
|
||||
className={`${styles.dropdown} ${styles.userDropdown}`}
|
||||
role="menu"
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} />
|
||||
<span>{section.title}</span>
|
||||
</Link>
|
||||
<li role="none">
|
||||
<Link
|
||||
to="/profile"
|
||||
role="menuitem"
|
||||
className={styles.dropdownLink}
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
>
|
||||
<UserRound size={16} strokeWidth={1.75} />
|
||||
پروفایل من
|
||||
</Link>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.dropdownLink}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false)
|
||||
setPasswordOpen(true)
|
||||
}}
|
||||
>
|
||||
<KeyRound size={16} strokeWidth={1.75} />
|
||||
تغییر پسورد
|
||||
</button>
|
||||
</li>
|
||||
{showAdminDashboardLink && (
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.dropdownLink}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false)
|
||||
redirectToApp('admin', '/')
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={16} strokeWidth={1.75} />
|
||||
پنل ادمین
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{showCustomerDashboardLink && (
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.dropdownLink}
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false)
|
||||
redirectToApp('customer', '/')
|
||||
}}
|
||||
>
|
||||
<Home size={16} strokeWidth={1.75} />
|
||||
پنل مشتری
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.logoutBtn}
|
||||
onClick={handleLogout}
|
||||
aria-label="خروج"
|
||||
>
|
||||
<LogOut size={18} strokeWidth={1.75} />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showLogout && (
|
||||
<div className={styles.actions}>
|
||||
{session && (
|
||||
<span className={styles.userName}>{session.user.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.logoutBtn}
|
||||
onClick={handleLogout}
|
||||
aria-label="خروج"
|
||||
>
|
||||
<LogOut size={18} strokeWidth={1.75} />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<ChangePasswordModal
|
||||
open={passwordOpen}
|
||||
userName={shortName}
|
||||
onClose={() => setPasswordOpen(false)}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,9 +91,9 @@
|
||||
|
||||
.itemRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(72px, auto) auto;
|
||||
gap: 10px 14px;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px 10px;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -108,25 +108,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemAmount {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemPrice {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.itemOption {
|
||||
font-size: 0.82rem;
|
||||
color: var(--brown);
|
||||
@@ -134,8 +115,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemOptionCalc {
|
||||
grid-column: 3;
|
||||
.itemCalc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -144,20 +124,39 @@
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.itemResult {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
text-align: end;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.itemFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed rgba(143, 65, 12, 0.18);
|
||||
}
|
||||
|
||||
.itemTotalLabel {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.itemTotal {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.itemsSummary {
|
||||
@@ -188,6 +187,44 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.discountRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(34, 140, 78, 0.12);
|
||||
border: 1px solid rgba(34, 140, 78, 0.28);
|
||||
color: #1b6b3a;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.payableRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.payableRow strong {
|
||||
color: var(--brown);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note {
|
||||
position: relative;
|
||||
margin-top: 16px;
|
||||
|
||||
@@ -3,12 +3,13 @@ import { X } from 'lucide-react'
|
||||
import {
|
||||
deliveryTypeLabel,
|
||||
formatOrderDateTime,
|
||||
formatOrderQuantityByUnit,
|
||||
formatShippingAddress,
|
||||
orderSellUnitLabel,
|
||||
orderStatusLabel,
|
||||
type Order,
|
||||
} from '../data/orders'
|
||||
import { formatCellNumber } from '../data/users'
|
||||
import { formatCellNumber, stripUserTitle } from '../data/users'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './CategoryModal.module.css'
|
||||
import localStyles from './OrderDetailsModal.module.css'
|
||||
@@ -19,6 +20,38 @@ type OrderDetailsModalProps = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function orderItemsSubtotal(order: Order) {
|
||||
return order.items.reduce((sum, item) => {
|
||||
const optionsTotal = (item.options ?? []).reduce(
|
||||
(optSum, option) => optSum + option.price,
|
||||
0,
|
||||
)
|
||||
return sum + Math.round(item.quantity * (item.unitPrice + optionsTotal))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function resolveOrderDiscount(order: Order) {
|
||||
if (order.discountCode) {
|
||||
return {
|
||||
code: order.discountCode,
|
||||
amount: order.discountAmount ?? 0,
|
||||
}
|
||||
}
|
||||
const match = order.note?.match(/کد\s*تخفیف:\s*([A-Za-z0-9_-]+)/i)
|
||||
if (!match?.[1]) return null
|
||||
return { code: match[1].toUpperCase(), amount: order.discountAmount ?? 0 }
|
||||
}
|
||||
|
||||
function noteWithoutDiscount(note?: string) {
|
||||
if (!note?.trim()) return undefined
|
||||
const cleaned = note
|
||||
.split(/\s*\|\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && !/^کد\s*تخفیف:/i.test(part))
|
||||
.join(' | ')
|
||||
return cleaned || undefined
|
||||
}
|
||||
|
||||
export function OrderDetailsModal({
|
||||
open,
|
||||
order,
|
||||
@@ -44,6 +77,15 @@ export function OrderDetailsModal({
|
||||
order.delivery.type === 'shipping'
|
||||
? formatShippingAddress(order.delivery.shippingAddress)
|
||||
: order.delivery.branch
|
||||
const itemsSubtotal = orderItemsSubtotal(order)
|
||||
const discount = resolveOrderDiscount(order)
|
||||
const displayNote = noteWithoutDiscount(order.note)
|
||||
const discountAmount =
|
||||
discount && discount.amount > 0
|
||||
? discount.amount
|
||||
: discount
|
||||
? Math.max(0, itemsSubtotal - order.totalPrice)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||
@@ -63,7 +105,7 @@ export function OrderDetailsModal({
|
||||
جزئیات سفارش
|
||||
</h2>
|
||||
<p className={localStyles.orderId} dir="ltr">
|
||||
{order.id}
|
||||
{order.code}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -87,7 +129,7 @@ export function OrderDetailsModal({
|
||||
<div>
|
||||
<dt>سفارشدهنده</dt>
|
||||
<dd>
|
||||
{order.customerName}
|
||||
{stripUserTitle(order.customerName)}
|
||||
<span className={localStyles.phone} dir="ltr">
|
||||
{formatCellNumber(order.customerPhone)}
|
||||
</span>
|
||||
@@ -110,41 +152,53 @@ export function OrderDetailsModal({
|
||||
<h3 className={localStyles.itemsTitle}>اقلام سفارش</h3>
|
||||
<ul className={localStyles.items}>
|
||||
{order.items.map((item) => {
|
||||
const productTotal = item.quantity * item.unitPrice
|
||||
const unitLabel = orderSellUnitLabel[item.sellUnit]
|
||||
const productTotal = Math.round(item.quantity * item.unitPrice)
|
||||
const itemOptions = item.options ?? []
|
||||
const optionTotal = itemOptions.reduce(
|
||||
(sum, option) => sum + item.quantity * option.price,
|
||||
0,
|
||||
)
|
||||
const lineTotal = productTotal + optionTotal
|
||||
const optionRows = itemOptions.map((option) => ({
|
||||
...option,
|
||||
total: Math.round(item.quantity * option.price),
|
||||
}))
|
||||
const lineTotal =
|
||||
productTotal +
|
||||
optionRows.reduce((sum, option) => sum + option.total, 0)
|
||||
|
||||
return (
|
||||
<li key={item.id} className={localStyles.item}>
|
||||
<div className={localStyles.itemRow}>
|
||||
<span className={localStyles.itemName}>{item.nameFa}</span>
|
||||
<span className={localStyles.itemAmount}>
|
||||
{formatPrice(item.quantity)}{' '}
|
||||
{orderSellUnitLabel[item.sellUnit]}
|
||||
<span className={localStyles.itemCalc} dir="rtl">
|
||||
{formatPrice(item.quantity)} <bdi>{unitLabel}</bdi>
|
||||
{' × '}
|
||||
{formatPrice(item.unitPrice)}
|
||||
</span>
|
||||
<span className={localStyles.itemPrice}>
|
||||
{formatPrice(productTotal)} تومان
|
||||
<span className={localStyles.itemResult} dir="rtl">
|
||||
= {formatPrice(productTotal)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{itemOptions.map((option) => (
|
||||
<div key={`${item.id}-${option.name}`} className={localStyles.itemRow}>
|
||||
{optionRows.map((option) => (
|
||||
<div
|
||||
key={option.id ?? `${item.id}-${option.name}`}
|
||||
className={localStyles.itemRow}
|
||||
>
|
||||
<span className={localStyles.itemOption}>
|
||||
{option.name}
|
||||
</span>
|
||||
<span className={localStyles.itemOptionCalc} dir="ltr">
|
||||
{formatPrice(item.quantity)}×{' '}
|
||||
<span className={localStyles.itemCalc} dir="rtl">
|
||||
{formatPrice(item.quantity)} <bdi>{unitLabel}</bdi>
|
||||
{' × '}
|
||||
{formatPrice(option.price)}
|
||||
</span>
|
||||
<span className={localStyles.itemResult} dir="rtl">
|
||||
= {formatPrice(option.total)} تومان
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={localStyles.itemFooter}>
|
||||
<span className={localStyles.itemTotal}>
|
||||
<span className={localStyles.itemTotalLabel}>جمع این قلم</span>
|
||||
<span className={localStyles.itemTotal} dir="ltr">
|
||||
{formatPrice(lineTotal)} تومان
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,16 +209,36 @@ export function OrderDetailsModal({
|
||||
|
||||
<div className={localStyles.itemsSummary}>
|
||||
<span className={localStyles.summaryItems}>
|
||||
جمع اقلام: {formatPrice(order.itemCount)}
|
||||
جمع اقلام:{' '}
|
||||
{formatOrderQuantityByUnit(order.items).join(' · ') ||
|
||||
formatPrice(order.itemCount)}
|
||||
</span>
|
||||
<span className={localStyles.summaryPrice}>
|
||||
{formatPrice(order.totalPrice)} تومان
|
||||
{formatPrice(itemsSubtotal)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.note && (
|
||||
{discount && (
|
||||
<div className={localStyles.discountRow} role="status">
|
||||
<span>
|
||||
{discount.code}
|
||||
{discountAmount > 0
|
||||
? ` — ${formatPrice(discountAmount)} تومان تخفیف`
|
||||
: ' — کد تخفیف اعمالشده'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discount && discountAmount > 0 && (
|
||||
<div className={localStyles.payableRow}>
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<strong>{formatPrice(order.totalPrice)} تومان</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayNote && (
|
||||
<p className={localStyles.note}>
|
||||
<span>یادداشت:</span> {order.note}
|
||||
<span>یادداشت:</span> {displayNote}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,14 @@ export function OrderLineItemModal({
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const qty = Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty <= 0) {
|
||||
const isWeight = product.sellUnit === 'kilo'
|
||||
|
||||
if (isWeight) {
|
||||
if (!Number.isFinite(qty) || qty < 0.1) {
|
||||
setError('وزن معتبر وارد کنید (حداقل ۰٫۱ کیلو)')
|
||||
return
|
||||
}
|
||||
} else if (!Number.isInteger(qty) || qty < 1) {
|
||||
setError('تعداد معتبر وارد کنید')
|
||||
return
|
||||
}
|
||||
@@ -97,6 +104,8 @@ export function OrderLineItemModal({
|
||||
})
|
||||
}
|
||||
|
||||
const isWeight = product.sellUnit === 'kilo'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.overlay} ${nestedStyles.overlay}`}
|
||||
@@ -147,9 +156,9 @@ export function OrderLineItemModal({
|
||||
<input
|
||||
id="order-line-qty"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
min={isWeight ? 0.1 : 1}
|
||||
step={isWeight ? 0.1 : 1}
|
||||
inputMode={isWeight ? 'decimal' : 'numeric'}
|
||||
dir="ltr"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import localStyles from './OrderStatusModal.module.css'
|
||||
|
||||
type OrderStatusModalProps = {
|
||||
open: boolean
|
||||
orderId: string
|
||||
orderCode: string
|
||||
currentStatus: OrderStatus
|
||||
onClose: () => void
|
||||
onSubmit: (status: OrderStatus) => void
|
||||
@@ -17,7 +17,7 @@ type OrderStatusModalProps = {
|
||||
|
||||
export function OrderStatusModal({
|
||||
open,
|
||||
orderId,
|
||||
orderCode,
|
||||
currentStatus,
|
||||
onClose,
|
||||
onSubmit,
|
||||
@@ -55,7 +55,7 @@ export function OrderStatusModal({
|
||||
تغییر وضعیت سفارش
|
||||
</h2>
|
||||
<p className={localStyles.orderId} dir="ltr">
|
||||
{orderId}
|
||||
{orderCode}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.calendar,
|
||||
.calendar :global(.rmdp-wrapper),
|
||||
.calendar :global(.rmdp-calendar) {
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.calendar :global(.rmdp-wrapper) {
|
||||
z-index: 200 !important;
|
||||
background: var(--dropdown-bg);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
0 18px 44px rgba(143, 65, 12, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.input {
|
||||
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;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
direction: ltr;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input: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);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import MultiDatePicker from 'react-multi-date-picker'
|
||||
import DateObjectModule from 'react-date-object'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
import styles from './PersianDateInput.module.css'
|
||||
|
||||
// CJS/ESM interop: Vite may expose the real export on `.default`
|
||||
const DatePicker =
|
||||
(MultiDatePicker as unknown as { default?: typeof MultiDatePicker }).default ??
|
||||
MultiDatePicker
|
||||
const DateObject =
|
||||
(DateObjectModule as unknown as { default?: typeof DateObjectModule }).default ??
|
||||
DateObjectModule
|
||||
|
||||
type PersianDateInputProps = {
|
||||
id?: string
|
||||
value: string
|
||||
onChange: (isoDate: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function isoToPickerValue(iso: string) {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return new DateObject({
|
||||
date,
|
||||
calendar: persian,
|
||||
locale: persian_fa,
|
||||
})
|
||||
}
|
||||
|
||||
function pickerValueToIso(value: InstanceType<typeof DateObject> | null) {
|
||||
if (!value) return ''
|
||||
const date = value.toDate()
|
||||
date.setHours(23, 59, 59, 0)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
export function PersianDateInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder = 'انتخاب تاریخ',
|
||||
}: PersianDateInputProps) {
|
||||
return (
|
||||
<DatePicker
|
||||
id={id}
|
||||
value={isoToPickerValue(value)}
|
||||
onChange={(date: InstanceType<typeof DateObject> | InstanceType<typeof DateObject>[] | null) => {
|
||||
if (date instanceof DateObject) {
|
||||
onChange(pickerValueToIso(date))
|
||||
return
|
||||
}
|
||||
onChange('')
|
||||
}}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
format="YYYY/MM/DD"
|
||||
calendarPosition="bottom-center"
|
||||
containerClassName={styles.container}
|
||||
className={styles.calendar}
|
||||
inputClass={styles.input}
|
||||
portal
|
||||
zIndex={200}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
editable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,15 @@
|
||||
animation: cardEnter 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.cardClickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardClickable:focus-visible {
|
||||
outline: 2px solid var(--brown);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.imageWrap {
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
@@ -164,12 +173,22 @@
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.iconBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.iconBtn:hover::after,
|
||||
.iconBtn:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.iconBtn:disabled::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
color: #9b2c2c;
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Pencil, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||||
import { formatPriceParts, type Product } from '../data/products'
|
||||
import { Pencil, SlidersHorizontal, Trash2, Eye } from 'lucide-react'
|
||||
import {
|
||||
formatPriceParts,
|
||||
productImageSrc,
|
||||
type Product,
|
||||
} from '../data/products'
|
||||
import styles from './ProductCard.module.css'
|
||||
|
||||
type ProductCardProps = {
|
||||
product: Product
|
||||
index: number
|
||||
visible: boolean
|
||||
busy?: boolean
|
||||
onView?: (product: Product) => void
|
||||
onEdit?: (product: Product) => void
|
||||
onOpenOptions?: (product: Product) => void
|
||||
onRemove?: (product: Product) => void
|
||||
@@ -15,6 +21,8 @@ export function ProductCard({
|
||||
product,
|
||||
index,
|
||||
visible,
|
||||
busy = false,
|
||||
onView,
|
||||
onEdit,
|
||||
onOpenOptions,
|
||||
onRemove,
|
||||
@@ -24,14 +32,32 @@ export function ProductCard({
|
||||
(product.options ?? []).map((option) => option.flavorId),
|
||||
).size
|
||||
|
||||
function stopAnd(handler?: (product: Product) => void) {
|
||||
return (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
handler?.(product)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${styles.card} ${visible ? styles.cardVisible : ''}`}
|
||||
className={`${styles.card} ${visible ? styles.cardVisible : ''} ${onView ? styles.cardClickable : ''}`}
|
||||
style={{ animationDelay: visible ? `${index * 0.08}s` : '0s' }}
|
||||
onClick={() => onView?.(product)}
|
||||
onKeyDown={(event) => {
|
||||
if (!onView) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onView(product)
|
||||
}
|
||||
}}
|
||||
role={onView ? 'link' : undefined}
|
||||
tabIndex={onView ? 0 : undefined}
|
||||
aria-label={onView ? `مشاهده ${product.nameFa}` : undefined}
|
||||
>
|
||||
<div className={styles.imageWrap}>
|
||||
<img
|
||||
src={product.image}
|
||||
src={productImageSrc(product)}
|
||||
alt={product.nameFa}
|
||||
className={styles.image}
|
||||
/>
|
||||
@@ -47,12 +73,24 @@ export function ProductCard({
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="مشاهده"
|
||||
data-tooltip="مشاهده"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onView)}
|
||||
>
|
||||
<Eye size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
onClick={() => onEdit?.(product)}
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onEdit)}
|
||||
>
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
@@ -64,7 +102,8 @@ export function ProductCard({
|
||||
optionCount > 0 ? `گزینهها (${optionCount})` : 'گزینهها'
|
||||
}
|
||||
data-tooltip="گزینهها"
|
||||
onClick={() => onOpenOptions?.(product)}
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onOpenOptions)}
|
||||
>
|
||||
<SlidersHorizontal size={17} strokeWidth={1.75} />
|
||||
{optionCount > 0 && (
|
||||
@@ -77,7 +116,8 @@ export function ProductCard({
|
||||
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
onClick={() => onRemove?.(product)}
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onRemove)}
|
||||
>
|
||||
<Trash2 size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
type Product,
|
||||
type ProductOptionValue,
|
||||
} from '../data/products'
|
||||
import { flavors, formatPrice, type FlavorBlock } from '../data/flavors'
|
||||
import { getSelectableCategoryOptions } from '../lib/categoryOptionsStore'
|
||||
import { formatPrice, type FlavorBlock } from '../data/flavors'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { getCategoryOptions } from '../lib/categoriesApi'
|
||||
import {
|
||||
AmountPriceModal,
|
||||
type AmountPriceValues,
|
||||
@@ -19,7 +20,10 @@ type ProductOptionsModalProps = {
|
||||
open: boolean
|
||||
product: Product | null
|
||||
onClose: () => void
|
||||
onSave: (productId: string, options: ProductOptionValue[]) => void
|
||||
onSave: (
|
||||
productId: string,
|
||||
options: ProductOptionValue[],
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
type EntryDraft = {
|
||||
@@ -29,6 +33,11 @@ type EntryDraft = {
|
||||
values: AmountPriceValues | null
|
||||
}
|
||||
|
||||
type OptionBlockView = FlavorBlock & {
|
||||
flavorNameFa?: string
|
||||
flavorNameEn?: string
|
||||
}
|
||||
|
||||
export function ProductOptionsModal({
|
||||
open,
|
||||
product,
|
||||
@@ -37,32 +46,65 @@ export function ProductOptionsModal({
|
||||
}: ProductOptionsModalProps) {
|
||||
const titleId = useId()
|
||||
const [values, setValues] = useState<ProductOptionValue[]>([])
|
||||
const [availableOptions, setAvailableOptions] = useState<OptionBlockView[]>(
|
||||
[],
|
||||
)
|
||||
const [selectedBlockId, setSelectedBlockId] = useState('')
|
||||
const [draft, setDraft] = useState<EntryDraft | null>(null)
|
||||
|
||||
const availableOptions = useMemo(() => {
|
||||
if (!product) return []
|
||||
return getSelectableCategoryOptions(product.category)
|
||||
}, [product, open])
|
||||
const [loadingOptions, setLoadingOptions] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !product) return
|
||||
|
||||
setValues(structuredClone(product.options ?? []))
|
||||
const first = getSelectableCategoryOptions(product.category)[0]
|
||||
setSelectedBlockId(first?.id ?? '')
|
||||
setDraft(null)
|
||||
setError('')
|
||||
setLoadingOptions(true)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const blocks = await getCategoryOptions(product.categoryId)
|
||||
const mapped: OptionBlockView[] = blocks
|
||||
.filter((block) => block.entries.length > 0)
|
||||
.map((block) => ({
|
||||
id: block.id,
|
||||
flavorId: block.flavorId,
|
||||
flavorNameFa: block.flavor?.nameFa,
|
||||
flavorNameEn: block.flavor?.nameEn,
|
||||
entries: block.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
amount: entry.amount,
|
||||
price: entry.price,
|
||||
})),
|
||||
}))
|
||||
setAvailableOptions(mapped)
|
||||
setSelectedBlockId(mapped[0]?.id ?? '')
|
||||
} catch (err) {
|
||||
setAvailableOptions([])
|
||||
setSelectedBlockId('')
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری آپشنهای دستهبندی ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoadingOptions(false)
|
||||
}
|
||||
})()
|
||||
}, [open, product])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !draft) onClose()
|
||||
if (event.key === 'Escape' && !draft && !saving) onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, draft, onClose])
|
||||
}, [open, draft, saving, onClose])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ProductOptionValue[]>()
|
||||
@@ -74,14 +116,22 @@ export function ProductOptionsModal({
|
||||
return Array.from(map.entries())
|
||||
}, [values])
|
||||
|
||||
const selectedBlock: FlavorBlock | undefined = availableOptions.find(
|
||||
const selectedBlock = availableOptions.find(
|
||||
(item) => item.id === selectedBlockId,
|
||||
)
|
||||
|
||||
if (!open || !product) return null
|
||||
|
||||
function flavorLabel(flavorId: string) {
|
||||
return flavors.find((item) => item.id === flavorId)?.nameFa ?? flavorId
|
||||
const fromOption = values.find(
|
||||
(item) => item.flavorId === flavorId && item.flavor,
|
||||
)?.flavor
|
||||
if (fromOption) return fromOption.nameFa
|
||||
|
||||
const fromBlock = availableOptions.find(
|
||||
(item) => item.flavorId === flavorId,
|
||||
)
|
||||
return fromBlock?.flavorNameFa ?? flavorId
|
||||
}
|
||||
|
||||
function handleAddSelectedOption() {
|
||||
@@ -128,6 +178,24 @@ export function ProductOptionsModal({
|
||||
setValues((current) => current.filter((item) => item.flavorId !== flavorId))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave(product.id, values)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'ذخیره آپشنها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const categoryName = product.category?.nameFa ?? 'این دسته'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||
@@ -158,16 +226,25 @@ export function ProductOptionsModal({
|
||||
className={styles.closeBtn}
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={optionStyles.body}>
|
||||
{availableOptions.length === 0 ? (
|
||||
{error && (
|
||||
<p className={optionStyles.empty} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loadingOptions ? (
|
||||
<p className={optionStyles.empty}>در حال بارگذاری آپشنها...</p>
|
||||
) : availableOptions.length === 0 ? (
|
||||
<p className={optionStyles.empty}>
|
||||
برای دسته «{product.category}» هنوز آپشنی در دستهبندیها ثبت
|
||||
نشده است.
|
||||
برای دسته «{categoryName}» هنوز آپشنی در دستهبندیها ثبت نشده
|
||||
است.
|
||||
</p>
|
||||
) : (
|
||||
<div className={productOptionStyles.picker}>
|
||||
@@ -181,26 +258,25 @@ export function ProductOptionsModal({
|
||||
value={selectedBlockId}
|
||||
onChange={(e) => setSelectedBlockId(e.target.value)}
|
||||
>
|
||||
{availableOptions.map((block) => {
|
||||
const flavor = flavors.find(
|
||||
(item) => item.id === block.flavorId,
|
||||
)
|
||||
return (
|
||||
<option key={block.id} value={block.id}>
|
||||
{flavor
|
||||
? `${flavor.nameFa} — ${flavor.nameEn}`
|
||||
: block.flavorId}
|
||||
{` (${block.entries.length})`}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
{availableOptions.map((block) => (
|
||||
<option key={block.id} value={block.id}>
|
||||
{block.flavorNameFa
|
||||
? `${block.flavorNameFa}${
|
||||
block.flavorNameEn
|
||||
? ` — ${block.flavorNameEn}`
|
||||
: ''
|
||||
}`
|
||||
: block.flavorId}
|
||||
{` (${block.entries.length})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={productOptionStyles.addSelectedBtn}
|
||||
onClick={handleAddSelectedOption}
|
||||
disabled={!selectedBlock}
|
||||
disabled={!selectedBlock || saving}
|
||||
>
|
||||
<Plus size={16} strokeWidth={1.75} />
|
||||
افزودن آپشن
|
||||
@@ -226,6 +302,7 @@ export function ProductOptionsModal({
|
||||
className={optionStyles.iconBtn}
|
||||
aria-label="افزودن مقدار"
|
||||
data-tooltip="افزودن مقدار"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
mode: 'create',
|
||||
@@ -241,6 +318,7 @@ export function ProductOptionsModal({
|
||||
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||
aria-label="حذف آپشن"
|
||||
data-tooltip="حذف آپشن"
|
||||
disabled={saving}
|
||||
onClick={() => handleRemoveFlavor(flavorId)}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.75} />
|
||||
@@ -265,6 +343,7 @@ export function ProductOptionsModal({
|
||||
className={optionStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
mode: 'edit',
|
||||
@@ -284,6 +363,7 @@ export function ProductOptionsModal({
|
||||
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={saving}
|
||||
onClick={() => handleRemoveValue(entry.id)}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
@@ -295,6 +375,7 @@ export function ProductOptionsModal({
|
||||
<button
|
||||
type="button"
|
||||
className={optionStyles.addAmountCard}
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
mode: 'create',
|
||||
@@ -319,15 +400,17 @@ export function ProductOptionsModal({
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => onSave(product.id, values)}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || loadingOptions}
|
||||
>
|
||||
ذخیره آپشنها
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره آپشنها'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
inset-inline-start: 14px;
|
||||
top: 18px;
|
||||
inset-inline-start: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
pointer-events: none;
|
||||
@@ -61,13 +61,32 @@
|
||||
|
||||
.editor {
|
||||
min-height: 160px;
|
||||
padding: 12px 14px;
|
||||
padding: 18px 20px;
|
||||
outline: none;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.editor :is(p, ul, ol, div) {
|
||||
margin: 0 0 0.65em;
|
||||
}
|
||||
|
||||
.editor :is(p, ul, ol, div):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor :is(ul, ol) {
|
||||
padding-inline-start: 1.4em;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.editor li {
|
||||
margin: 0.2em 0;
|
||||
padding-inline-start: 0.15em;
|
||||
}
|
||||
|
||||
+20
-3
@@ -118,16 +118,33 @@ export function removeCategory(
|
||||
}))
|
||||
}
|
||||
|
||||
export type FlatCategoryOption = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
labelFa: string
|
||||
labelEn: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export function flattenCategories(
|
||||
categories: Category[],
|
||||
prefix = '',
|
||||
): { id: string; labelFa: string; labelEn: string }[] {
|
||||
depth = 0,
|
||||
): FlatCategoryOption[] {
|
||||
return categories.flatMap((category) => {
|
||||
const labelFa = prefix ? `${prefix} / ${category.nameFa}` : category.nameFa
|
||||
const labelEn = prefix ? `${prefix} / ${category.nameEn}` : category.nameEn
|
||||
return [
|
||||
{ id: category.id, labelFa, labelEn },
|
||||
...flattenCategories(category.children, labelFa),
|
||||
{
|
||||
id: category.id,
|
||||
nameFa: category.nameFa,
|
||||
nameEn: category.nameEn,
|
||||
labelFa,
|
||||
labelEn,
|
||||
depth,
|
||||
},
|
||||
...flattenCategories(category.children, labelFa, depth + 1),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
+34
-146
@@ -11,12 +11,14 @@ export type OrderStatus =
|
||||
export type DeliveryType = 'shipping' | 'pickup'
|
||||
|
||||
export type OrderItemOption = {
|
||||
id?: string
|
||||
name: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type OrderItem = {
|
||||
id: string
|
||||
productId?: string | null
|
||||
nameFa: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
@@ -40,11 +42,15 @@ export type OrderDelivery =
|
||||
| {
|
||||
type: 'pickup'
|
||||
branch: string
|
||||
branchId?: string | null
|
||||
}
|
||||
|
||||
export type Order = {
|
||||
id: string
|
||||
code: string
|
||||
number: number
|
||||
createdAt: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
itemCount: number
|
||||
@@ -53,6 +59,8 @@ export type Order = {
|
||||
delivery: OrderDelivery
|
||||
items: OrderItem[]
|
||||
note?: string
|
||||
discountCode?: string
|
||||
discountAmount?: number
|
||||
}
|
||||
|
||||
export const orderStatuses: { id: OrderStatus; label: string }[] = [
|
||||
@@ -83,84 +91,36 @@ export const orderSellUnitLabel: Record<SellUnit, string> = {
|
||||
kilo: 'کیلو',
|
||||
}
|
||||
|
||||
const customers = [
|
||||
{ name: 'سارا محمدی', phone: '09121234567' },
|
||||
{ name: 'علی رضایی', phone: '09129876543' },
|
||||
{ name: 'مریم حسینی', phone: '09351234567' },
|
||||
{ name: 'حسین کریمی', phone: '09123456789' },
|
||||
{ name: 'نازنین احمدی', phone: '09127654321' },
|
||||
{ name: 'رضا نوری', phone: '09361234567' },
|
||||
{ name: 'فاطمه جعفری', phone: '09121112233' },
|
||||
{ name: 'امیر صادقی', phone: '09125556677' },
|
||||
]
|
||||
/** Aggregate order quantities by sell unit for list display. */
|
||||
export function formatOrderQuantityByUnit(
|
||||
items: Array<{ quantity: number; sellUnit: SellUnit }>,
|
||||
): string[] {
|
||||
let unitTotal = 0
|
||||
let kiloTotal = 0
|
||||
|
||||
const productNames = [
|
||||
'شیرینی پسته',
|
||||
'کیک شکلاتی',
|
||||
'باقلوا گردو',
|
||||
'نان بربری',
|
||||
'ماکارون',
|
||||
'شیرینی نخودچی',
|
||||
'کیک هویج',
|
||||
'رولت خامه',
|
||||
]
|
||||
for (const item of items) {
|
||||
if (item.sellUnit === 'kilo') kiloTotal += item.quantity
|
||||
else unitTotal += item.quantity
|
||||
}
|
||||
|
||||
const optionChoices: OrderItemOption[] = [
|
||||
{ name: 'شکلات اضافه', price: 50000 },
|
||||
{ name: 'بسته کادویی', price: 25000 },
|
||||
{ name: 'نوع شکلات تلخ', price: 40000 },
|
||||
{ name: 'وزن ۱ کیلو', price: 80000 },
|
||||
{ name: 'روکش شکلات', price: 35000 },
|
||||
]
|
||||
|
||||
const branches = ['شعبه مرکزی', 'شعبه صفاییه', 'شعبه نیروی هوایی']
|
||||
|
||||
const shippingAddresses: ShippingAddress[] = [
|
||||
{
|
||||
id: 'addr-1',
|
||||
name: 'منزل',
|
||||
district: 'خیابان ارم',
|
||||
address: 'قم، خیابان ارم، کوچه ۵، پلاک ۱۲',
|
||||
landline: '02531234567',
|
||||
},
|
||||
{
|
||||
id: 'addr-2',
|
||||
name: 'محل کار',
|
||||
district: 'بلوار امین',
|
||||
address: 'قم، بلوار امین، مجتمع تجاری نور، واحد ۴',
|
||||
landline: '02537654321',
|
||||
},
|
||||
{
|
||||
id: 'addr-3',
|
||||
name: 'منزل',
|
||||
district: 'صفاییه',
|
||||
address: 'قم، خیابان شهید بهشتی، پلاک ۸۷',
|
||||
landline: '02531112233',
|
||||
},
|
||||
{
|
||||
id: 'addr-4',
|
||||
name: 'منزل والدین',
|
||||
district: 'حرم',
|
||||
address: 'قم، میدان جهاد، خیابان امام، پلاک ۲۳',
|
||||
landline: '02534445566',
|
||||
},
|
||||
]
|
||||
|
||||
export function formatShippingAddress(address: ShippingAddress) {
|
||||
const landline = address.landline
|
||||
? ` — ${address.landline}`
|
||||
: ''
|
||||
return `${address.name} · ${address.district} — ${address.address}${landline}`
|
||||
const lines: string[] = []
|
||||
if (unitTotal > 0) {
|
||||
lines.push(
|
||||
`${unitTotal.toLocaleString('fa-IR')} ${orderSellUnitLabel.unit}`,
|
||||
)
|
||||
}
|
||||
if (kiloTotal > 0) {
|
||||
lines.push(
|
||||
`${kiloTotal.toLocaleString('fa-IR')} ${orderSellUnitLabel.kilo}`,
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
const statuses: OrderStatus[] = [
|
||||
'pending',
|
||||
'confirmed',
|
||||
'preparing',
|
||||
'ready',
|
||||
'delivered',
|
||||
'cancelled',
|
||||
]
|
||||
export function formatShippingAddress(address: ShippingAddress) {
|
||||
const landline = address.landline ? ` — ${address.landline}` : ''
|
||||
return `${address.name} · ${address.district} — ${address.address}${landline}`
|
||||
}
|
||||
|
||||
export function getItemUnitTotal(item: OrderItem) {
|
||||
const optionsTotal = (item.options ?? []).reduce(
|
||||
@@ -174,78 +134,6 @@ export function getItemLineTotal(item: OrderItem) {
|
||||
return item.quantity * getItemUnitTotal(item)
|
||||
}
|
||||
|
||||
function buildItems(seed: number): OrderItem[] {
|
||||
const count = (seed % 4) + 1
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const name = productNames[(seed + index) % productNames.length]
|
||||
const quantity = ((seed + index) % 3) + 1
|
||||
const unitPrice = [95000, 120000, 185000, 265000, 310000, 420000][
|
||||
(seed + index) % 6
|
||||
]
|
||||
const sellUnit: SellUnit =
|
||||
name.includes('کیک') || name.includes('باقلوا') ? 'kilo' : 'unit'
|
||||
const withOptions = (seed + index) % 3 !== 0
|
||||
const firstOption = optionChoices[(seed + index) % optionChoices.length]
|
||||
const secondOption =
|
||||
optionChoices[(seed + index + 2) % optionChoices.length]
|
||||
const chosenOptions =
|
||||
(seed + index) % 2 === 0 && firstOption.name !== secondOption.name
|
||||
? [firstOption, secondOption]
|
||||
: [firstOption]
|
||||
return {
|
||||
id: `item-${seed}-${index}`,
|
||||
nameFa: name,
|
||||
quantity,
|
||||
unitPrice,
|
||||
sellUnit,
|
||||
options: withOptions ? chosenOptions : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildDelivery(index: number): OrderDelivery {
|
||||
if (index % 2 === 0) {
|
||||
return {
|
||||
type: 'shipping',
|
||||
shippingAddress: shippingAddresses[index % shippingAddresses.length],
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'pickup',
|
||||
branch: branches[index % branches.length],
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrders(): Order[] {
|
||||
return Array.from({ length: 28 }, (_, index) => {
|
||||
const customer = customers[index % customers.length]
|
||||
const items = buildItems(index + 1)
|
||||
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
const totalPrice = items.reduce(
|
||||
(sum, item) => sum + getItemLineTotal(item),
|
||||
0,
|
||||
)
|
||||
const day = String((index % 28) + 1).padStart(2, '0')
|
||||
const hour = String(10 + (index % 10)).padStart(2, '0')
|
||||
const minute = String((index * 7) % 60).padStart(2, '0')
|
||||
|
||||
return {
|
||||
id: `BL-${1400 + index}`,
|
||||
createdAt: `2026-07-${day}T${hour}:${minute}:00`,
|
||||
customerName: customer.name,
|
||||
customerPhone: customer.phone,
|
||||
itemCount,
|
||||
totalPrice,
|
||||
status: statuses[index % statuses.length],
|
||||
delivery: buildDelivery(index),
|
||||
items,
|
||||
note: index % 5 === 0 ? 'تحویل در ساعت ۱۷' : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const orders: Order[] = buildOrders()
|
||||
|
||||
export function formatOrderDateTime(iso: string): { date: string; time: string } {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
||||
+39
-99
@@ -1,17 +1,29 @@
|
||||
import img1010 from '../images/products/IMG_1010.jpeg'
|
||||
import img1031 from '../images/products/IMG_1031.jpeg'
|
||||
import img1035 from '../images/products/IMG_1035.jpeg'
|
||||
import img1039 from '../images/products/IMG_1039.jpeg'
|
||||
import img1040 from '../images/products/IMG_1040.jpeg'
|
||||
import img1043 from '../images/products/IMG_1043.jpeg'
|
||||
|
||||
export type SellUnit = 'unit' | 'kilo'
|
||||
|
||||
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 = {
|
||||
@@ -20,18 +32,25 @@ export type Product = {
|
||||
nameEn: string
|
||||
price: number
|
||||
sellUnit: SellUnit
|
||||
category: string
|
||||
image: string
|
||||
categoryId: string
|
||||
category: ProductCategoryRef
|
||||
intro?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
mainImageUrl: string | null
|
||||
mainImageKey?: string | null
|
||||
gallery?: ProductGalleryImage[]
|
||||
options: ProductOptionValue[]
|
||||
}
|
||||
|
||||
export const productCategories = [
|
||||
'شیرینی',
|
||||
'کیک',
|
||||
'باقلوا',
|
||||
'نان',
|
||||
'ماکارون',
|
||||
] as const
|
||||
export const productImagePlaceholder =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
|
||||
<rect width="400" height="400" fill="#f3ebe3"/>
|
||||
<text x="200" y="210" text-anchor="middle" fill="#b08a6a" font-family="sans-serif" font-size="28">محصول</text>
|
||||
</svg>`,
|
||||
)
|
||||
|
||||
function optionId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
@@ -50,89 +69,6 @@ export function createProductOptionValue(
|
||||
}
|
||||
}
|
||||
|
||||
export const products: Product[] = [
|
||||
{
|
||||
id: '1',
|
||||
nameFa: 'شیرینی پسته',
|
||||
nameEn: 'Pistachio Cookie',
|
||||
price: 185000,
|
||||
sellUnit: 'unit',
|
||||
category: 'شیرینی',
|
||||
image: img1010,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nameFa: 'کیک شکلاتی',
|
||||
nameEn: 'Chocolate Cake',
|
||||
price: 420000,
|
||||
sellUnit: 'kilo',
|
||||
category: 'کیک',
|
||||
image: img1031,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nameFa: 'باقلوا گردو',
|
||||
nameEn: 'Walnut Baklava',
|
||||
price: 265000,
|
||||
sellUnit: 'kilo',
|
||||
category: 'باقلوا',
|
||||
image: img1035,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nameFa: 'نان خامهای',
|
||||
nameEn: 'Cream Puff',
|
||||
price: 95000,
|
||||
sellUnit: 'unit',
|
||||
category: 'نان',
|
||||
image: img1039,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nameFa: 'تارت میوه',
|
||||
nameEn: 'Fruit Tart',
|
||||
price: 310000,
|
||||
sellUnit: 'unit',
|
||||
category: 'کیک',
|
||||
image: img1040,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
nameFa: 'ماکارون بلوط',
|
||||
nameEn: 'Balout Macaron',
|
||||
price: 150000,
|
||||
sellUnit: 'unit',
|
||||
category: 'ماکارون',
|
||||
image: img1043,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
nameFa: 'کروسان کرهای',
|
||||
nameEn: 'Butter Croissant',
|
||||
price: 120000,
|
||||
sellUnit: 'unit',
|
||||
category: 'نان',
|
||||
image: img1010,
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
nameFa: 'چیزکیک وانیل',
|
||||
nameEn: 'Vanilla Cheesecake',
|
||||
price: 380000,
|
||||
sellUnit: 'kilo',
|
||||
category: 'کیک',
|
||||
image: img1040,
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const sellUnitLabel: Record<SellUnit, string> = {
|
||||
unit: 'واحد',
|
||||
kilo: 'کیلو',
|
||||
@@ -147,3 +83,7 @@ export function formatPriceParts(product: Product): {
|
||||
label: `تومان - ${sellUnitLabel[product.sellUnit]}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function productImageSrc(product: Product) {
|
||||
return product.mainImageUrl || productImagePlaceholder
|
||||
}
|
||||
|
||||
@@ -34,6 +34,19 @@ export const userTitles: UserTitle[] = [
|
||||
'سرکار خانم مهندس',
|
||||
]
|
||||
|
||||
/** Strip honorific title prefix from a full display name. */
|
||||
export function stripUserTitle(fullName: string) {
|
||||
const sorted = [...userTitles].sort((a, b) => b.length - a.length)
|
||||
const trimmed = fullName.trim()
|
||||
for (const title of sorted) {
|
||||
if (trimmed === title) return ''
|
||||
if (trimmed.startsWith(`${title} `)) {
|
||||
return trimmed.slice(title.length).trim()
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export const userRoles: { id: UserRole; label: string }[] = [
|
||||
{ id: 'customer', label: 'مشتری' },
|
||||
{ id: 'admin', label: 'ادمین' },
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
--glass-border: rgba(255, 255, 255, 0.78);
|
||||
--glass-border-soft: rgba(143, 65, 12, 0.14);
|
||||
--glass-shadow: 0 18px 48px rgba(143, 65, 12, 0.12);
|
||||
--dropdown-bg: rgba(255, 250, 250, 0.97);
|
||||
--glass-shine: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.78) 0%,
|
||||
|
||||
@@ -117,3 +117,47 @@ export async function apiRequest<T>(
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
type UploadOptions = {
|
||||
auth?: boolean
|
||||
skipRefresh?: boolean
|
||||
}
|
||||
|
||||
/** Multipart upload helper (do not set Content-Type; browser sets boundary). */
|
||||
export async function apiUpload<T>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
options: UploadOptions = {},
|
||||
): Promise<T> {
|
||||
const { auth = true, skipRefresh = false } = options
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth) {
|
||||
const token = getAccessToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth && !skipRefresh) {
|
||||
const refreshed = await tryRefreshSession()
|
||||
if (refreshed) {
|
||||
return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
parseErrorMessage(payload, 'خطایی رخ داد'),
|
||||
)
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
+88
-5
@@ -1,6 +1,7 @@
|
||||
import type { UserRole } from '../data/users'
|
||||
|
||||
const AUTH_KEY = 'balout.admin.auth'
|
||||
const AUTH_COOKIE = 'balout.auth'
|
||||
const LEGACY_AUTH_KEY = 'balout.admin.auth'
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
@@ -31,9 +32,57 @@ function isSession(value: unknown): value is AuthSession {
|
||||
)
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
function cookieDomain() {
|
||||
const host = window.location.hostname
|
||||
// Browsers reject Domain=.baloutpastry.com when the page is on localhost.
|
||||
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)}=`
|
||||
const parts = document.cookie.split(';')
|
||||
for (const part of parts) {
|
||||
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('; ')
|
||||
}
|
||||
|
||||
function readLegacySession(): AuthSession | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTH_KEY)
|
||||
const raw = localStorage.getItem(LEGACY_AUTH_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isSession(parsed) ? parsed : null
|
||||
@@ -42,12 +91,36 @@ export function getSession(): AuthSession | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
try {
|
||||
const raw = readCookie(AUTH_COOKIE)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (isSession(parsed)) return parsed
|
||||
}
|
||||
|
||||
// Migrate older localStorage sessions once.
|
||||
const legacy = readLegacySession()
|
||||
if (legacy) {
|
||||
setSession(legacy)
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY)
|
||||
return legacy
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession) {
|
||||
localStorage.setItem(AUTH_KEY, JSON.stringify(session))
|
||||
// ~7 days, aligned with refresh token lifetime
|
||||
writeCookie(AUTH_COOKIE, JSON.stringify(session), 60 * 60 * 24 * 7)
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(AUTH_KEY)
|
||||
clearCookie(AUTH_COOKIE)
|
||||
localStorage.removeItem(LEGACY_AUTH_KEY)
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
@@ -65,3 +138,13 @@ export function getRefreshToken(): string | null {
|
||||
export function getAuthUser(): AuthUser | null {
|
||||
return getSession()?.user ?? null
|
||||
}
|
||||
|
||||
export function patchSessionUser(user: AuthUser) {
|
||||
const session = getSession()
|
||||
if (!session) return
|
||||
setSession({ ...session, user })
|
||||
}
|
||||
|
||||
export function isElevatedRole(role: UserRole | undefined | null) {
|
||||
return role === 'admin' || role === 'superAdmin'
|
||||
}
|
||||
|
||||
+84
-6
@@ -7,12 +7,13 @@ export type AuthTokensResponse = {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, password },
|
||||
})
|
||||
export type OtpSendCodeResponse = {
|
||||
ok: true
|
||||
expiresInSeconds: number
|
||||
resendAfterSeconds: number
|
||||
}
|
||||
|
||||
function setAuthSession(data: AuthTokensResponse) {
|
||||
setSession({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
@@ -21,6 +22,83 @@ export async function login(cellNumber: string, password: string) {
|
||||
return data.user
|
||||
}
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, password },
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function loginSendCode(cellNumber: string) {
|
||||
return apiRequest<OtpSendCodeResponse>('/auth/login/send-code', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
export async function loginVerify(cellNumber: string, code: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/login/verify', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function forgotPasswordSendCode(cellNumber: string) {
|
||||
return apiRequest<OtpSendCodeResponse>('/auth/forgot-password/send-code', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
export async function forgotPasswordVerify(cellNumber: string, code: string) {
|
||||
return apiRequest<{ ok: true }>('/auth/forgot-password/verify', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
}
|
||||
|
||||
export async function forgotPasswordReset(input: {
|
||||
cellNumber: string
|
||||
code: string
|
||||
newPassword: string
|
||||
}) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/forgot-password/reset', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: input,
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function registerSendCode(input: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
cellNumber: string
|
||||
password: string
|
||||
}) {
|
||||
return apiRequest<OtpSendCodeResponse>('/auth/register/send-code', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function registerVerify(cellNumber: string, code: string) {
|
||||
const data = await apiRequest<AuthTokensResponse>('/auth/register/verify', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
return setAuthSession(data)
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const refreshToken = getRefreshToken()
|
||||
try {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
export type BranchRecord = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'balout-branches'
|
||||
|
||||
const seedBranches: BranchRecord[] = [
|
||||
{
|
||||
id: 'branch-1',
|
||||
name: 'شعبه مرکزی',
|
||||
district: 'حرم',
|
||||
address: 'قم، خیابان ارم، نبش کوچه ۳',
|
||||
landline: '02531230001',
|
||||
cellNumber: '09120000001',
|
||||
},
|
||||
{
|
||||
id: 'branch-2',
|
||||
name: 'شعبه صفاییه',
|
||||
district: 'صفاییه',
|
||||
address: 'قم، صفاییه، بلوار شهید بهشتی',
|
||||
landline: '02531230002',
|
||||
cellNumber: '09120000002',
|
||||
},
|
||||
{
|
||||
id: 'branch-3',
|
||||
name: 'شعبه نیروی هوایی',
|
||||
district: 'نیروگاه',
|
||||
address: 'قم، شهرک نیروی هوایی، فاز ۲',
|
||||
landline: '02531230003',
|
||||
cellNumber: '09120000003',
|
||||
},
|
||||
]
|
||||
|
||||
function readBranches(): BranchRecord[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(seedBranches))
|
||||
return seedBranches
|
||||
}
|
||||
const parsed = JSON.parse(raw) as BranchRecord[]
|
||||
return parsed.length > 0 ? parsed : seedBranches
|
||||
} catch {
|
||||
return seedBranches
|
||||
}
|
||||
}
|
||||
|
||||
export function getBranches(): BranchRecord[] {
|
||||
return readBranches().filter((branch) => branch.name.trim())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Category } from '../data/categories'
|
||||
import type { Flavor } from '../data/flavors'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type CategoryOptionEntry = {
|
||||
id: string
|
||||
amount: string
|
||||
price: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type CategoryOptionBlock = {
|
||||
id: string
|
||||
categoryId: string
|
||||
flavorId: string
|
||||
sortOrder: number
|
||||
flavor: Flavor
|
||||
entries: CategoryOptionEntry[]
|
||||
}
|
||||
|
||||
export type CreateCategoryPayload = {
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
parentId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type UpdateCategoryPayload = {
|
||||
nameFa?: string
|
||||
nameEn?: string
|
||||
parentId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type ReplaceCategoryOptionsPayload = {
|
||||
blocks: {
|
||||
flavorId: string
|
||||
sortOrder?: number
|
||||
entries: { amount: string; price: number }[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return apiRequest<Category[]>('/categories')
|
||||
}
|
||||
|
||||
export function createCategory(payload: CreateCategoryPayload) {
|
||||
return apiRequest<Category>('/categories', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCategory(id: string, payload: UpdateCategoryPayload) {
|
||||
return apiRequest<Category>(`/categories/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/categories/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function getCategoryOptions(categoryId: string) {
|
||||
return apiRequest<CategoryOptionBlock[]>(`/categories/${categoryId}/options`)
|
||||
}
|
||||
|
||||
export function replaceCategoryOptions(
|
||||
categoryId: string,
|
||||
payload: ReplaceCategoryOptionsPayload,
|
||||
) {
|
||||
return apiRequest<CategoryOptionBlock[]>(
|
||||
`/categories/${categoryId}/options`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type DiscountUser = {
|
||||
id: string
|
||||
title: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
cellNumber: string
|
||||
role: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type DiscountCategory = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
}
|
||||
|
||||
export type Discount = {
|
||||
id: string
|
||||
code: string
|
||||
categoryId: string | null
|
||||
category: DiscountCategory | null
|
||||
userId: string | null
|
||||
user: DiscountUser | null
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
active: boolean
|
||||
createdByAdminId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
expired: boolean
|
||||
general: boolean
|
||||
}
|
||||
|
||||
export type DiscountListResponse = {
|
||||
items: Discount[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ListDiscountsParams = {
|
||||
q?: string
|
||||
code?: string
|
||||
expiresOn?: string
|
||||
user?: string
|
||||
userId?: string
|
||||
generalOnly?: boolean
|
||||
active?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type CreateDiscountPayload = {
|
||||
code: string
|
||||
userId?: string | null
|
||||
categoryId?: string | null
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export type UpdateDiscountPayload = Partial<CreateDiscountPayload>
|
||||
|
||||
function buildQuery(params: ListDiscountsParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
if (params.code?.trim()) search.set('code', params.code.trim())
|
||||
if (params.expiresOn) search.set('expiresOn', params.expiresOn)
|
||||
if (params.user?.trim()) search.set('user', params.user.trim())
|
||||
if (params.userId) search.set('userId', params.userId)
|
||||
if (params.generalOnly !== undefined) {
|
||||
search.set('generalOnly', String(params.generalOnly))
|
||||
}
|
||||
if (params.active !== undefined) search.set('active', String(params.active))
|
||||
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 listDiscounts(params: ListDiscountsParams = {}) {
|
||||
return apiRequest<DiscountListResponse>(`/discounts${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function listMyDiscounts(params: ListDiscountsParams = {}) {
|
||||
return apiRequest<DiscountListResponse>(
|
||||
`/discounts/mine${buildQuery(params)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function createDiscount(payload: CreateDiscountPayload) {
|
||||
return apiRequest<Discount>('/discounts', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDiscount(id: string, payload: UpdateDiscountPayload) {
|
||||
return apiRequest<Discount>(`/discounts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDiscount(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/discounts/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Flavor } from '../data/flavors'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export function listFlavors() {
|
||||
return apiRequest<Flavor[]>('/flavors')
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type AppKind = 'admin' | 'customer'
|
||||
|
||||
function stripPort(host: string) {
|
||||
return host.split(':')[0]?.toLowerCase() ?? host.toLowerCase()
|
||||
}
|
||||
|
||||
export function getAdminHost() {
|
||||
return (
|
||||
(import.meta.env.VITE_ADMIN_HOST as string | undefined) ??
|
||||
'admin.baloutpastry.com'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCustomerHost() {
|
||||
return (
|
||||
(import.meta.env.VITE_CUSTOMER_HOST as string | undefined) ??
|
||||
'customer.baloutpastry.com'
|
||||
)
|
||||
}
|
||||
|
||||
export function getHostname() {
|
||||
return stripPort(window.location.hostname)
|
||||
}
|
||||
|
||||
export function isLocalDevHost(hostname = getHostname()) {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1'
|
||||
}
|
||||
|
||||
/** Only the admin host is admin; localhost and other hosts use customer (single login). */
|
||||
export function getAppKind(hostname = getHostname()): AppKind {
|
||||
if (hostname === getAdminHost()) return 'admin'
|
||||
return 'customer'
|
||||
}
|
||||
|
||||
export function isApexHost(hostname = getHostname()) {
|
||||
return hostname === 'baloutpastry.com'
|
||||
}
|
||||
|
||||
export function buildAppOrigin(kind: AppKind) {
|
||||
const host = kind === 'admin' ? getAdminHost() : getCustomerHost()
|
||||
const { protocol, port } = window.location
|
||||
const portPart = port ? `:${port}` : ''
|
||||
return `${protocol}//${host}${portPart}`
|
||||
}
|
||||
|
||||
export function redirectToApp(kind: AppKind, path = '/') {
|
||||
const target = `${buildAppOrigin(kind)}${path.startsWith('/') ? path : `/${path}`}`
|
||||
window.location.replace(target)
|
||||
}
|
||||
|
||||
/** Public storefront home (Website). */
|
||||
export function getWebsiteOrigin() {
|
||||
return (
|
||||
(import.meta.env.VITE_WEBSITE_URL as string | undefined)?.replace(/\/$/, '') ||
|
||||
'http://baloutpastry.com:5174'
|
||||
)
|
||||
}
|
||||
|
||||
export function redirectToWebsite(path = '/') {
|
||||
const base = getWebsiteOrigin()
|
||||
const target = `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
window.location.replace(target)
|
||||
}
|
||||
|
||||
/** Single login entry — always on the customer app. */
|
||||
export function redirectToLogin() {
|
||||
redirectToApp('customer', '/login')
|
||||
}
|
||||
|
||||
export function redirectApexToCustomer() {
|
||||
redirectToApp('customer', '/')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiUpload } from './api'
|
||||
|
||||
export type MediaKind = 'main' | 'gallery' | 'temp'
|
||||
|
||||
export type MediaUploadResult = {
|
||||
url: string
|
||||
storageKey: string
|
||||
storageDisk: string
|
||||
}
|
||||
|
||||
export function uploadMedia(kind: MediaKind, file: Blob | File, fileName?: string) {
|
||||
const formData = new FormData()
|
||||
const name =
|
||||
fileName ??
|
||||
(file instanceof File && file.name ? file.name : `upload-${kind}.jpg`)
|
||||
formData.append('file', file, name)
|
||||
return apiUpload<MediaUploadResult>(`/media/upload?kind=${kind}`, formData)
|
||||
}
|
||||
|
||||
export async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
|
||||
const response = await fetch(dataUrl)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export function isLocalImageSrc(src: string) {
|
||||
return (
|
||||
src.startsWith('data:') ||
|
||||
src.startsWith('blob:') ||
|
||||
src.startsWith('file:')
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
CakeSlice,
|
||||
FolderTree,
|
||||
MapPin,
|
||||
Package,
|
||||
Percent,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
UserRound,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -93,6 +96,15 @@ export const navSections: NavSection[] = [
|
||||
icon: Package,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'discounts',
|
||||
title: 'کدهای تخفیف',
|
||||
titleEn: 'Discounts',
|
||||
description: 'تعریف و مدیریت کد تخفیف کاربران',
|
||||
href: '/discounts',
|
||||
icon: Percent,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'تنظیمات',
|
||||
@@ -103,3 +115,52 @@ export const navSections: NavSection[] = [
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** Header links — settings & discounts stay on home tiles only. */
|
||||
export const adminHeaderSections: NavSection[] = navSections.filter(
|
||||
(section) => section.id !== 'settings' && section.id !== 'discounts',
|
||||
)
|
||||
|
||||
export const customerNavSections: NavSection[] = [
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'پروفایل من',
|
||||
titleEn: 'Profile',
|
||||
description: 'مشاهده و ویرایش اطلاعات حساب کاربری',
|
||||
href: '/profile',
|
||||
icon: UserRound,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
title: 'سفارشهای من',
|
||||
titleEn: 'My Orders',
|
||||
description: 'پیگیری سفارشهای ثبتشده',
|
||||
href: '/orders',
|
||||
icon: Package,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'discounts',
|
||||
title: 'تخفیفهای من',
|
||||
titleEn: 'Discounts',
|
||||
description: 'کدها و پیشنهادهای تخفیف شما',
|
||||
href: '/discounts',
|
||||
icon: Percent,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
title: 'آدرسهای من',
|
||||
titleEn: 'Addresses',
|
||||
description: 'مدیریت آدرسهای ارسال',
|
||||
href: '/addresses',
|
||||
icon: MapPin,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** Header links only — profile lives under the name menu. */
|
||||
export const customerHeaderSections: NavSection[] = customerNavSections.filter(
|
||||
(section) => section.id !== 'profile',
|
||||
)
|
||||
|
||||
+42
-67
@@ -1,7 +1,8 @@
|
||||
import { products, type Product } from '../data/products'
|
||||
import { users, formatCellNumber, type User } from '../data/users'
|
||||
import { flavors } from '../data/flavors'
|
||||
import { getSelectableCategoryOptions } from './categoryOptionsStore'
|
||||
import { listProducts } from './productsApi'
|
||||
import { listUsers } from './usersApi'
|
||||
import { formatCellNumber } from '../data/users'
|
||||
import type { Product } from '../data/products'
|
||||
import { ApiError } from './api'
|
||||
|
||||
export type SearchUserResult = {
|
||||
id: string
|
||||
@@ -16,76 +17,50 @@ export type ProductOptionChoice = {
|
||||
price: number
|
||||
}
|
||||
|
||||
function delay(ms = 280) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** Simulated API user search (`GET /users?q=`). */
|
||||
/** User search via `GET /users?q=`. Falls back to empty on error. */
|
||||
export async function searchUsers(query: string): Promise<SearchUserResult[]> {
|
||||
await delay()
|
||||
const q = query.trim().toLowerCase()
|
||||
const digits = q.replace(/\D/g, '')
|
||||
|
||||
return users
|
||||
.filter((user) => !user.disabled)
|
||||
.filter((user) => {
|
||||
if (!q) return true
|
||||
const fullName = `${user.firstName} ${user.lastName}`
|
||||
return (
|
||||
fullName.includes(query.trim()) ||
|
||||
user.firstName.includes(query.trim()) ||
|
||||
user.lastName.includes(query.trim()) ||
|
||||
(digits.length > 0 && user.cellNumber.includes(digits))
|
||||
)
|
||||
try {
|
||||
const result = await listUsers({
|
||||
q: query.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
phone: user.cellNumber,
|
||||
phoneDisplay: formatCellNumber(user.cellNumber),
|
||||
}))
|
||||
return result.items
|
||||
.filter((user) => !user.disabled)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
phone: user.cellNumber,
|
||||
phoneDisplay: formatCellNumber(user.cellNumber),
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) return []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulated API product search (`GET /products?q=`). */
|
||||
/** Product search via `GET /products?q=`. */
|
||||
export async function searchProducts(query: string): Promise<Product[]> {
|
||||
await delay()
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return products.slice(0, 12)
|
||||
|
||||
return products
|
||||
.filter(
|
||||
(product) =>
|
||||
product.nameFa.includes(query.trim()) ||
|
||||
product.nameEn.toLowerCase().includes(q) ||
|
||||
product.category.includes(query.trim()),
|
||||
)
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
function flavorLabel(flavorId: string) {
|
||||
return flavors.find((item) => item.id === flavorId)?.nameFa ?? flavorId
|
||||
try {
|
||||
const result = await listProducts({
|
||||
q: query.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
})
|
||||
return result.items.map((item) => ({
|
||||
...item,
|
||||
options: item.options ?? [],
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Options available when adding a product to an order. */
|
||||
export function getProductOrderOptions(product: Product): ProductOptionChoice[] {
|
||||
if (product.options.length > 0) {
|
||||
return product.options.map((option) => ({
|
||||
id: option.id,
|
||||
name: `${flavorLabel(option.flavorId)} — ${option.amount}`,
|
||||
price: option.price,
|
||||
}))
|
||||
}
|
||||
|
||||
return getSelectableCategoryOptions(product.category).flatMap((block) =>
|
||||
block.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: `${flavorLabel(block.flavorId)} — ${entry.amount}`,
|
||||
price: entry.price,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function findUserById(id: string): User | undefined {
|
||||
return users.find((user) => user.id === id)
|
||||
return (product.options ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
name: `${option.flavor?.nameFa ?? option.flavorId} — ${option.amount}`,
|
||||
price: option.price,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Order, OrderStatus } from '../data/orders'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type OrderListResponse = {
|
||||
items: Order[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ListOrdersParams = {
|
||||
q?: string
|
||||
status?: OrderStatus | ''
|
||||
customerId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type CreateOrderItemPayload = {
|
||||
productId: string
|
||||
quantity: number
|
||||
optionIds?: string[]
|
||||
}
|
||||
|
||||
export type CreateOrderPayload = {
|
||||
customerId: string
|
||||
deliveryType: 'pickup' | 'shipping'
|
||||
branchId?: string
|
||||
shippingAddressId?: string
|
||||
note?: string
|
||||
items: CreateOrderItemPayload[]
|
||||
}
|
||||
|
||||
function buildQuery(params: ListOrdersParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
if (params.status) search.set('status', params.status)
|
||||
if (params.customerId) search.set('customerId', params.customerId)
|
||||
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 listOrders(params: ListOrdersParams = {}) {
|
||||
return apiRequest<OrderListResponse>(`/orders${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function listMyOrders(params: ListOrdersParams = {}) {
|
||||
return apiRequest<OrderListResponse>(`/orders/mine${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getOrder(id: string) {
|
||||
return apiRequest<Order>(`/orders/${id}`)
|
||||
}
|
||||
|
||||
export function getMyOrder(id: string) {
|
||||
return apiRequest<Order>(`/orders/mine/${id}`)
|
||||
}
|
||||
|
||||
export function createOrder(payload: CreateOrderPayload) {
|
||||
return apiRequest<Order>('/orders', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateOrderStatus(id: string, status: OrderStatus) {
|
||||
return apiRequest<Order>(`/orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
Product,
|
||||
ProductGalleryImage,
|
||||
ProductOptionValue,
|
||||
SellUnit,
|
||||
} from '../data/products'
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type ProductListResponse = {
|
||||
items: Product[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ListProductsParams = {
|
||||
q?: string
|
||||
categoryId?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type ProductOptionPayload = {
|
||||
flavorId: string
|
||||
amount: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type ProductGalleryPayload = {
|
||||
url: string
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
export type CreateProductPayload = {
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
categoryId: string
|
||||
price: number
|
||||
sellUnit: SellUnit
|
||||
intro?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
mainImageUrl?: string
|
||||
mainImageKey?: string
|
||||
gallery?: ProductGalleryPayload[]
|
||||
options?: ProductOptionPayload[]
|
||||
}
|
||||
|
||||
export type UpdateProductPayload = Partial<CreateProductPayload>
|
||||
|
||||
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.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 apiRequest<ProductListResponse>(`/products${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return apiRequest<Product>(`/products/${id}`)
|
||||
}
|
||||
|
||||
export function createProduct(payload: CreateProductPayload) {
|
||||
return apiRequest<Product>('/products', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProduct(id: string, payload: UpdateProductPayload) {
|
||||
return apiRequest<Product>(`/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProduct(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function toOptionPayload(
|
||||
options: ProductOptionValue[],
|
||||
): ProductOptionPayload[] {
|
||||
return options.map(({ flavorId, amount, price }) => ({
|
||||
flavorId,
|
||||
amount,
|
||||
price,
|
||||
}))
|
||||
}
|
||||
|
||||
export function toGalleryPayload(
|
||||
gallery: ProductGalleryImage[],
|
||||
): ProductGalleryPayload[] {
|
||||
return gallery.map(({ url, storageKey }) => ({ url, storageKey }))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { apiRequest } from './api'
|
||||
|
||||
export type Branch = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type ShippingException = {
|
||||
id: string
|
||||
district: string
|
||||
price: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CreateBranchPayload = {
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline?: string
|
||||
cellNumber?: string
|
||||
}
|
||||
|
||||
export type UpdateBranchPayload = Partial<CreateBranchPayload>
|
||||
|
||||
export type ReplaceShippingPayload = {
|
||||
exceptions: { district: string; price: number }[]
|
||||
}
|
||||
|
||||
export function listDistricts() {
|
||||
return apiRequest<string[]>('/settings/districts')
|
||||
}
|
||||
|
||||
export function listShippingExceptions() {
|
||||
return apiRequest<ShippingException[]>('/settings/shipping')
|
||||
}
|
||||
|
||||
export function replaceShippingExceptions(payload: ReplaceShippingPayload) {
|
||||
return apiRequest<ShippingException[]>('/settings/shipping', {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function listBranches() {
|
||||
return apiRequest<Branch[]>('/settings/branches')
|
||||
}
|
||||
|
||||
export function createBranch(payload: CreateBranchPayload) {
|
||||
return apiRequest<Branch>('/settings/branches', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBranch(id: string, payload: UpdateBranchPayload) {
|
||||
return apiRequest<Branch>(`/settings/branches/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBranch(id: string) {
|
||||
return apiRequest<{ ok: true }>(`/settings/branches/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { ShippingAddress } from '../data/orders'
|
||||
|
||||
const STORAGE_KEY = 'balout-user-addresses'
|
||||
|
||||
type AddressMap = Record<string, ShippingAddress[]>
|
||||
|
||||
function uid() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
function seedForUser(userId: string): ShippingAddress[] {
|
||||
const seeds: Record<string, ShippingAddress[]> = {
|
||||
'1': [
|
||||
{
|
||||
id: 'u1-a1',
|
||||
name: 'منزل',
|
||||
district: 'خیابان ارم',
|
||||
address: 'قم، خیابان ارم، کوچه ۵، پلاک ۱۲',
|
||||
landline: '02531234567',
|
||||
},
|
||||
{
|
||||
id: 'u1-a2',
|
||||
name: 'مطب',
|
||||
district: 'بلوار امین',
|
||||
address: 'قم، بلوار امین، ساختمان پزشکان، طبقه ۳',
|
||||
landline: '02537654321',
|
||||
},
|
||||
],
|
||||
'2': [
|
||||
{
|
||||
id: 'u2-a1',
|
||||
name: 'منزل',
|
||||
district: 'صفاییه',
|
||||
address: 'قم، صفاییه، خیابان شهید بهشتی، پلاک ۴۵',
|
||||
landline: '02531112233',
|
||||
},
|
||||
],
|
||||
}
|
||||
return (
|
||||
seeds[userId] ?? [
|
||||
{
|
||||
id: `${userId}-default`,
|
||||
name: 'منزل',
|
||||
district: 'حرم',
|
||||
address: 'قم، اطراف حرم، پلاک ۱۰',
|
||||
landline: '',
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
function readMap(): AddressMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
return JSON.parse(raw) as AddressMap
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeMap(map: AddressMap) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
||||
}
|
||||
|
||||
export function getUserAddresses(userId: string): ShippingAddress[] {
|
||||
const map = readMap()
|
||||
if (!map[userId]) {
|
||||
map[userId] = seedForUser(userId)
|
||||
writeMap(map)
|
||||
}
|
||||
return map[userId]
|
||||
}
|
||||
|
||||
export function addUserAddress(
|
||||
userId: string,
|
||||
input: Omit<ShippingAddress, 'id'>,
|
||||
): ShippingAddress {
|
||||
const map = readMap()
|
||||
const list = map[userId] ?? seedForUser(userId)
|
||||
const address: ShippingAddress = { ...input, id: uid() }
|
||||
map[userId] = [...list, address]
|
||||
writeMap(map)
|
||||
return address
|
||||
}
|
||||
@@ -54,6 +54,10 @@ export function listUsers(params: ListUsersParams = {}) {
|
||||
return apiRequest<UserListResponse>(`/users${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getUser(id: string) {
|
||||
return apiRequest<User>(`/users/${id}`)
|
||||
}
|
||||
|
||||
export function createUser(payload: CreateUserPayload) {
|
||||
return apiRequest<User>('/users', {
|
||||
method: 'POST',
|
||||
@@ -71,6 +75,17 @@ export function updateUser(id: string, payload: UpdateUserPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getMyProfile() {
|
||||
return apiRequest<User>('/users/me')
|
||||
}
|
||||
|
||||
export function updateMyProfile(payload: UpdateUserPayload) {
|
||||
return apiRequest<User>('/users/me', {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateUserRole(id: string, role: UserRole) {
|
||||
return apiRequest<User>(`/users/${id}/role`, {
|
||||
method: 'PATCH',
|
||||
@@ -90,3 +105,64 @@ export function deleteUser(id: string) {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export type UserAddress = {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CreateUserAddressPayload = {
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline?: string
|
||||
}
|
||||
|
||||
export type UpdateUserAddressPayload = Partial<CreateUserAddressPayload>
|
||||
|
||||
export function listUserAddresses(userId: string) {
|
||||
return apiRequest<UserAddress[]>(`/users/${userId}/addresses`)
|
||||
}
|
||||
|
||||
export function createUserAddress(
|
||||
userId: string,
|
||||
payload: CreateUserAddressPayload,
|
||||
) {
|
||||
return apiRequest<UserAddress>(`/users/${userId}/addresses`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function listMyAddresses() {
|
||||
return apiRequest<UserAddress[]>('/users/me/addresses')
|
||||
}
|
||||
|
||||
export function createMyAddress(payload: CreateUserAddressPayload) {
|
||||
return apiRequest<UserAddress>('/users/me/addresses', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMyAddress(
|
||||
addressId: string,
|
||||
payload: UpdateUserAddressPayload,
|
||||
) {
|
||||
return apiRequest<UserAddress>(`/users/me/addresses/${addressId}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMyAddress(addressId: string) {
|
||||
return apiRequest<{ ok: true }>(`/users/me/addresses/${addressId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -173,11 +173,19 @@
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fffdfc;
|
||||
background: var(--dropdown-bg);
|
||||
backdrop-filter: blur(16px) saturate(1.15);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.15);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.14);
|
||||
}
|
||||
|
||||
.optionNested {
|
||||
margin-inline-start: calc(var(--nest-depth, 1) * 16px);
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 2px solid rgba(143, 65, 12, 0.18);
|
||||
}
|
||||
|
||||
.option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
+412
-199
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ChevronRight, ChevronDown, Search } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
@@ -10,9 +17,23 @@ import { TagInput } from '../components/TagInput'
|
||||
import {
|
||||
findCategory,
|
||||
flattenCategories,
|
||||
initialCategories,
|
||||
type Category,
|
||||
} from '../data/categories'
|
||||
import type { SellUnit } from '../data/products'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import { listCategories } from '../lib/categoriesApi'
|
||||
import {
|
||||
dataUrlToBlob,
|
||||
isLocalImageSrc,
|
||||
uploadMedia,
|
||||
} from '../lib/mediaApi'
|
||||
import {
|
||||
createProduct,
|
||||
getProduct,
|
||||
updateProduct,
|
||||
} from '../lib/productsApi'
|
||||
import { parsePriceNumber } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './AddProductPage.module.css'
|
||||
@@ -20,9 +41,15 @@ import pageStyles from './AddProductPage.module.css'
|
||||
export function AddProductPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { productId } = useParams<{ productId?: string }>()
|
||||
const isEdit = Boolean(productId)
|
||||
const formId = useId()
|
||||
|
||||
const [mainImage, setMainImage] = useState<string | null>(null)
|
||||
const [existingMainKey, setExistingMainKey] = useState<string | null>(null)
|
||||
const [existingGalleryKeys, setExistingGalleryKeys] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||
const [nameFa, setNameFa] = useState('')
|
||||
const [nameEn, setNameEn] = useState('')
|
||||
@@ -33,14 +60,17 @@ export function AddProductPage() {
|
||||
const [gallery, setGallery] = useState<string[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
const [categoryQuery, setCategoryQuery] = useState('')
|
||||
const [categoryOpen, setCategoryOpen] = useState(false)
|
||||
const categoryRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => flattenCategories(initialCategories),
|
||||
[],
|
||||
() => flattenCategories(categories),
|
||||
[categories],
|
||||
)
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
@@ -54,7 +84,7 @@ export function AddProductPage() {
|
||||
}, [categoryOptions, categoryQuery])
|
||||
|
||||
const selectedCategory = categoryId
|
||||
? findCategory(initialCategories, categoryId)
|
||||
? findCategory(categories, categoryId)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +97,109 @@ export function AddProductPage() {
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const tree = await listCategories()
|
||||
setCategories(tree)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری دستهبندیها ناموفق بود.',
|
||||
)
|
||||
}
|
||||
})()
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return
|
||||
|
||||
void (async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const product = await getProduct(productId)
|
||||
setNameFa(product.nameFa)
|
||||
setNameEn(product.nameEn)
|
||||
setCategoryId(product.categoryId)
|
||||
setSellUnit(product.sellUnit)
|
||||
setPrice(String(product.price))
|
||||
setIntro(product.intro ?? '')
|
||||
setDescription(product.description ?? '')
|
||||
setTags(product.tags ?? [])
|
||||
setMainImage(product.mainImageUrl)
|
||||
setExistingMainKey(product.mainImageKey ?? null)
|
||||
|
||||
const galleryItems = product.gallery ?? []
|
||||
setGallery(galleryItems.map((item) => item.url))
|
||||
setExistingGalleryKeys(
|
||||
Object.fromEntries(
|
||||
galleryItems.map((item) => [item.url, item.storageKey]),
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری محصول ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [productId, navigate])
|
||||
|
||||
async function resolveMainImage() {
|
||||
if (!mainImage) throw new Error('تصویر اصلی الزامی است')
|
||||
|
||||
if (!isLocalImageSrc(mainImage)) {
|
||||
return {
|
||||
mainImageUrl: mainImage,
|
||||
mainImageKey: existingMainKey ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await dataUrlToBlob(mainImage)
|
||||
const uploaded = await uploadMedia('main', blob)
|
||||
return {
|
||||
mainImageUrl: uploaded.url,
|
||||
mainImageKey: uploaded.storageKey,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGallery() {
|
||||
const items: { url: string; storageKey: string }[] = []
|
||||
|
||||
for (const src of gallery) {
|
||||
if (!isLocalImageSrc(src)) {
|
||||
const key = existingGalleryKeys[src]
|
||||
if (!key) {
|
||||
throw new Error('کلید ذخیرهسازی تصویر گالری یافت نشد')
|
||||
}
|
||||
items.push({ url: src, storageKey: key })
|
||||
continue
|
||||
}
|
||||
|
||||
const blob = await dataUrlToBlob(src)
|
||||
const uploaded = await uploadMedia('gallery', blob)
|
||||
items.push({ url: uploaded.url, storageKey: uploaded.storageKey })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!mainImage) {
|
||||
setError('تصویر اصلی الزامی است')
|
||||
@@ -88,8 +220,49 @@ export function AddProductPage() {
|
||||
}
|
||||
|
||||
setError('')
|
||||
window.alert('محصول با موفقیت ثبت شد (نسخه نمایشی)')
|
||||
navigate('/products/list')
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const main = await resolveMainImage()
|
||||
const galleryItems = await resolveGallery()
|
||||
|
||||
const payload = {
|
||||
nameFa: nameFa.trim(),
|
||||
nameEn: nameEn.trim(),
|
||||
categoryId,
|
||||
price: numericPrice,
|
||||
sellUnit,
|
||||
intro: intro.trim(),
|
||||
description,
|
||||
tags,
|
||||
mainImageUrl: main.mainImageUrl,
|
||||
mainImageKey: main.mainImageKey,
|
||||
gallery: galleryItems,
|
||||
}
|
||||
|
||||
if (isEdit && productId) {
|
||||
await updateProduct(productId, payload)
|
||||
} else {
|
||||
await createProduct(payload)
|
||||
}
|
||||
|
||||
navigate('/products/list')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'ذخیره محصول ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -99,7 +272,7 @@ export function AddProductPage() {
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
New Product
|
||||
{isEdit ? 'Edit Product' : 'New Product'}
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
@@ -108,206 +281,246 @@ export function AddProductPage() {
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>ثبت محصول جدید</h1>
|
||||
<h1 className={styles.headline}>
|
||||
{isEdit ? 'ویرایش محصول' : 'ثبت محصول جدید'}
|
||||
</h1>
|
||||
<p className={styles.lead}>
|
||||
اطلاعات محصول را تکمیل کنید و ذخیره نمایید.
|
||||
{isEdit
|
||||
? 'اطلاعات محصول را ویرایش و ذخیره کنید.'
|
||||
: 'اطلاعات محصول را تکمیل کنید و ذخیره نمایید.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
className={pageStyles.form}
|
||||
onSubmit={handleSubmit}
|
||||
aria-labelledby={`${formId}-title`}
|
||||
>
|
||||
<h2 id={`${formId}-title`} className={pageStyles.srOnly}>
|
||||
فرم ثبت محصول
|
||||
</h2>
|
||||
{loading ? (
|
||||
<p className={pageStyles.error}>در حال بارگذاری...</p>
|
||||
) : (
|
||||
<form
|
||||
className={pageStyles.form}
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
aria-labelledby={`${formId}-title`}
|
||||
>
|
||||
<h2 id={`${formId}-title`} className={pageStyles.srOnly}>
|
||||
{isEdit ? 'فرم ویرایش محصول' : 'فرم ثبت محصول'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.panel}>
|
||||
<section className={pageStyles.section}>
|
||||
<div className={pageStyles.topGrid}>
|
||||
<div>
|
||||
<h3 className={pageStyles.sectionTitle}>تصویر اصلی</h3>
|
||||
<ImageCropper
|
||||
value={mainImage}
|
||||
onChange={setMainImage}
|
||||
aspect={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={pageStyles.sectionTitle}>اطلاعات پایه</h3>
|
||||
|
||||
<div className={pageStyles.field} ref={categoryRef}>
|
||||
<label htmlFor="product-category">دستهبندی</label>
|
||||
<div className={pageStyles.searchWrap}>
|
||||
<Search size={16} className={pageStyles.searchIcon} />
|
||||
<input
|
||||
id="product-category"
|
||||
type="text"
|
||||
placeholder="جستجوی دستهبندی..."
|
||||
value={
|
||||
categoryOpen
|
||||
? categoryQuery
|
||||
: selectedCategory
|
||||
? `${selectedCategory.nameFa} — ${selectedCategory.nameEn}`
|
||||
: categoryQuery
|
||||
}
|
||||
onChange={(e) => {
|
||||
setCategoryQuery(e.target.value)
|
||||
setCategoryOpen(true)
|
||||
if (categoryId) setCategoryId(null)
|
||||
}}
|
||||
onFocus={() => {
|
||||
setCategoryOpen(true)
|
||||
setCategoryQuery('')
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ChevronDown size={16} className={pageStyles.chevron} />
|
||||
</div>
|
||||
|
||||
{categoryOpen && (
|
||||
<ul className={pageStyles.dropdown} role="listbox">
|
||||
{filteredCategories.length === 0 ? (
|
||||
<li className={pageStyles.emptyOption}>موردی یافت نشد</li>
|
||||
) : (
|
||||
filteredCategories.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.option} ${categoryId === option.id ? pageStyles.optionActive : ''}`}
|
||||
onClick={() => {
|
||||
setCategoryId(option.id)
|
||||
setCategoryQuery('')
|
||||
setCategoryOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className={pageStyles.optionFa}>
|
||||
{option.labelFa}
|
||||
</span>
|
||||
<span className={pageStyles.optionEn}>
|
||||
{option.labelEn}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-name-fa">نام فارسی</label>
|
||||
<input
|
||||
id="product-name-fa"
|
||||
type="text"
|
||||
placeholder="مثال: شیرینی پسته"
|
||||
value={nameFa}
|
||||
onChange={(e) => setNameFa(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-name-en">نام انگلیسی</label>
|
||||
<input
|
||||
id="product-name-en"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="Example: Pistachio Cookie"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.priceRow}>
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-sell-unit">نوع قیمت</label>
|
||||
<select
|
||||
id="product-sell-unit"
|
||||
value={sellUnit}
|
||||
onChange={(e) =>
|
||||
setSellUnit(e.target.value as SellUnit)
|
||||
}
|
||||
>
|
||||
<option value="unit">برای واحد</option>
|
||||
<option value="kilo">برای وزن (کیلو)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-price">قیمت (تومان)</label>
|
||||
<PriceInput
|
||||
id="product-price"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="۰"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-intro">معرفی کوتاه</label>
|
||||
<textarea
|
||||
id="product-intro"
|
||||
rows={3}
|
||||
placeholder="یک معرفی کوتاه از محصول بنویسید..."
|
||||
value={intro}
|
||||
onChange={(e) => setIntro(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className={pageStyles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className={`${pageStyles.section} ${pageStyles.sectionDivider}`}>
|
||||
<RichTextArea
|
||||
id="product-description"
|
||||
label="توضیحات"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="توضیحات کامل محصول را بنویسید..."
|
||||
/>
|
||||
</section>
|
||||
<div className={pageStyles.panel}>
|
||||
<section className={pageStyles.section}>
|
||||
<div className={pageStyles.topGrid}>
|
||||
<div>
|
||||
<h3 className={pageStyles.sectionTitle}>تصویر اصلی</h3>
|
||||
<ImageCropper
|
||||
value={mainImage}
|
||||
onChange={setMainImage}
|
||||
aspect={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className={`${pageStyles.section} ${pageStyles.sectionBand}`}>
|
||||
<ImageGallery
|
||||
label="گالری تصاویر"
|
||||
images={gallery}
|
||||
onChange={setGallery}
|
||||
/>
|
||||
</section>
|
||||
<div>
|
||||
<h3 className={pageStyles.sectionTitle}>اطلاعات پایه</h3>
|
||||
|
||||
<section className={pageStyles.section}>
|
||||
<TagInput
|
||||
label="تگها"
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder="تگ را بنویسید و Enter بزنید"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className={pageStyles.field} ref={categoryRef}>
|
||||
<label htmlFor="product-category">دستهبندی</label>
|
||||
<div className={pageStyles.searchWrap}>
|
||||
<Search size={16} className={pageStyles.searchIcon} />
|
||||
<input
|
||||
id="product-category"
|
||||
type="text"
|
||||
placeholder="جستجوی دستهبندی..."
|
||||
value={
|
||||
categoryOpen
|
||||
? categoryQuery
|
||||
: selectedCategory
|
||||
? `${selectedCategory.nameFa} — ${selectedCategory.nameEn}`
|
||||
: categoryQuery
|
||||
}
|
||||
onChange={(e) => {
|
||||
setCategoryQuery(e.target.value)
|
||||
setCategoryOpen(true)
|
||||
if (categoryId) setCategoryId(null)
|
||||
}}
|
||||
onFocus={() => {
|
||||
setCategoryOpen(true)
|
||||
setCategoryQuery('')
|
||||
}}
|
||||
autoComplete="off"
|
||||
disabled={saving}
|
||||
/>
|
||||
<ChevronDown size={16} className={pageStyles.chevron} />
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.cancelBtn}
|
||||
onClick={() => navigate('/products')}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button type="submit" className={pageStyles.submitBtn}>
|
||||
ذخیره محصول
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{categoryOpen && (
|
||||
<ul className={pageStyles.dropdown} role="listbox">
|
||||
{filteredCategories.length === 0 ? (
|
||||
<li className={pageStyles.emptyOption}>
|
||||
موردی یافت نشد
|
||||
</li>
|
||||
) : (
|
||||
filteredCategories.map((option) => (
|
||||
<li
|
||||
key={option.id}
|
||||
className={
|
||||
option.depth > 0
|
||||
? pageStyles.optionNested
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
{
|
||||
'--nest-depth': option.depth,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.option} ${categoryId === option.id ? pageStyles.optionActive : ''}`}
|
||||
onClick={() => {
|
||||
setCategoryId(option.id)
|
||||
setCategoryQuery('')
|
||||
setCategoryOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className={pageStyles.optionFa}>
|
||||
{option.nameFa}
|
||||
</span>
|
||||
<span className={pageStyles.optionEn}>
|
||||
{option.nameEn}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-name-fa">نام فارسی</label>
|
||||
<input
|
||||
id="product-name-fa"
|
||||
type="text"
|
||||
placeholder="مثال: شیرینی پسته"
|
||||
value={nameFa}
|
||||
onChange={(e) => setNameFa(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-name-en">نام انگلیسی</label>
|
||||
<input
|
||||
id="product-name-en"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="Example: Pistachio Cookie"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.priceRow}>
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-sell-unit">نوع قیمت</label>
|
||||
<select
|
||||
id="product-sell-unit"
|
||||
value={sellUnit}
|
||||
onChange={(e) =>
|
||||
setSellUnit(e.target.value as SellUnit)
|
||||
}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="unit">برای واحد</option>
|
||||
<option value="kilo">برای وزن (کیلو)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-price">قیمت (تومان)</label>
|
||||
<PriceInput
|
||||
id="product-price"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
placeholder="۰"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor="product-intro">معرفی کوتاه</label>
|
||||
<textarea
|
||||
id="product-intro"
|
||||
rows={3}
|
||||
placeholder="یک معرفی کوتاه از محصول بنویسید..."
|
||||
value={intro}
|
||||
onChange={(e) => setIntro(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${pageStyles.section} ${pageStyles.sectionDivider}`}
|
||||
>
|
||||
<RichTextArea
|
||||
id="product-description"
|
||||
label="توضیحات"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="توضیحات کامل محصول را بنویسید..."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${pageStyles.section} ${pageStyles.sectionBand}`}
|
||||
>
|
||||
<ImageGallery
|
||||
label="گالری تصاویر"
|
||||
images={gallery}
|
||||
onChange={setGallery}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className={pageStyles.section}>
|
||||
<TagInput
|
||||
label="تگها"
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder="تگ را بنویسید و Enter بزنید"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.cancelBtn}
|
||||
onClick={() => navigate('/products/list')}
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={pageStyles.submitBtn}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? 'در حال ذخیره...'
|
||||
: isEdit
|
||||
? 'ذخیره تغییرات'
|
||||
: 'ذخیره محصول'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
+161
-54
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Plus } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { CategoryItem } from '../components/CategoryItem'
|
||||
@@ -8,31 +8,85 @@ import {
|
||||
type CategoryFormValues,
|
||||
} from '../components/CategoryModal'
|
||||
import { CategoryOptionsModal } from '../components/CategoryOptionsModal'
|
||||
import {
|
||||
addChildCategory,
|
||||
createCategory,
|
||||
findCategory,
|
||||
initialCategories,
|
||||
removeCategory,
|
||||
updateCategory,
|
||||
type Category,
|
||||
} from '../data/categories'
|
||||
import { findCategory, type Category } from '../data/categories'
|
||||
import type { CategoryFlavorOptions } from '../data/flavors'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import {
|
||||
getCategoryOptionsByName,
|
||||
removeCategoryOptionsByName,
|
||||
setCategoryOptionsByName,
|
||||
} from '../lib/categoryOptionsStore'
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
getCategoryOptions,
|
||||
listCategories,
|
||||
replaceCategoryOptions,
|
||||
updateCategory,
|
||||
} from '../lib/categoriesApi'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './CategoriesPage.module.css'
|
||||
|
||||
function mapOptionsFromApi(
|
||||
blocks: Awaited<ReturnType<typeof getCategoryOptions>>,
|
||||
): CategoryFlavorOptions {
|
||||
return blocks.map((block) => ({
|
||||
id: block.id,
|
||||
flavorId: block.flavorId,
|
||||
entries: block.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
amount: entry.amount,
|
||||
price: entry.price,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export function CategoriesPage() {
|
||||
const location = useLocation()
|
||||
const [categories, setCategories] = useState<Category[]>(initialCategories)
|
||||
const navigate = useNavigate()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [initialParentId, setInitialParentId] = useState<string | null>(null)
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [optionsCategory, setOptionsCategory] = useState<Category | null>(null)
|
||||
const [optionsInitial, setOptionsInitial] = useState<CategoryFlavorOptions>(
|
||||
[],
|
||||
)
|
||||
const [optionsLoading, setOptionsLoading] = useState(false)
|
||||
|
||||
const handleAuthError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const tree = await listCategories()
|
||||
setCategories(tree)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setCategories([])
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری دستهبندیها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
void loadCategories()
|
||||
}, [loadCategories])
|
||||
|
||||
function openCreateModal(parentId: string | null = null) {
|
||||
setEditingCategory(null)
|
||||
@@ -52,47 +106,88 @@ export function CategoriesPage() {
|
||||
setInitialParentId(null)
|
||||
}
|
||||
|
||||
function handleSubmit(values: CategoryFormValues) {
|
||||
if (values.id) {
|
||||
const previous = findCategory(categories, values.id)
|
||||
setCategories((current) =>
|
||||
updateCategory(current, values.id!, {
|
||||
async function handleSubmit(values: CategoryFormValues) {
|
||||
setError('')
|
||||
try {
|
||||
if (values.id) {
|
||||
await updateCategory(values.id, {
|
||||
nameFa: values.nameFa,
|
||||
nameEn: values.nameEn,
|
||||
}),
|
||||
)
|
||||
if (previous && previous.nameFa !== values.nameFa) {
|
||||
const options = getCategoryOptionsByName(previous.nameFa)
|
||||
if (options.length > 0) {
|
||||
setCategoryOptionsByName(values.nameFa, options)
|
||||
removeCategoryOptionsByName(previous.nameFa)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await createCategory({
|
||||
nameFa: values.nameFa,
|
||||
nameEn: values.nameEn,
|
||||
parentId: values.parentId ?? null,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const category = createCategory(values.nameFa, values.nameEn)
|
||||
setCategories((current) => {
|
||||
if (!values.parentId) return [...current, category]
|
||||
return addChildCategory(current, values.parentId, category)
|
||||
})
|
||||
closeCategoryModal()
|
||||
await loadCategories()
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'ذخیره دستهبندی ناموفق بود.',
|
||||
)
|
||||
}
|
||||
closeCategoryModal()
|
||||
}
|
||||
|
||||
function handleRemove(id: string) {
|
||||
async function handleRemove(id: string) {
|
||||
const confirmed = window.confirm('این دستهبندی حذف شود؟')
|
||||
if (!confirmed) return
|
||||
const target = findCategory(categories, id)
|
||||
setCategories((current) => removeCategory(current, id))
|
||||
if (target) removeCategoryOptionsByName(target.nameFa)
|
||||
|
||||
setError('')
|
||||
try {
|
||||
await deleteCategory(id)
|
||||
await loadCategories()
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'حذف دستهبندی ناموفق بود.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveOptions(
|
||||
_categoryId: string,
|
||||
async function handleOpenOptions(category: Category) {
|
||||
setOptionsCategory(category)
|
||||
setOptionsInitial([])
|
||||
setOptionsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const blocks = await getCategoryOptions(category.id)
|
||||
setOptionsInitial(mapOptionsFromApi(blocks))
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setOptionsCategory(null)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری آپشنهای دستهبندی ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setOptionsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveOptions(
|
||||
categoryId: string,
|
||||
options: CategoryFlavorOptions,
|
||||
) {
|
||||
if (!optionsCategory) return
|
||||
setCategoryOptionsByName(optionsCategory.nameFa, options)
|
||||
await replaceCategoryOptions(categoryId, {
|
||||
blocks: options
|
||||
.filter((block) => block.flavorId && block.entries.length > 0)
|
||||
.map((block, index) => ({
|
||||
flavorId: block.flavorId,
|
||||
sortOrder: index,
|
||||
entries: block.entries.map((entry) => ({
|
||||
amount: entry.amount,
|
||||
price: entry.price,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
setOptionsCategory(null)
|
||||
setOptionsInitial([])
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -119,7 +214,15 @@ export function CategoriesPage() {
|
||||
</section>
|
||||
|
||||
<section className={pageStyles.panel} aria-label="فهرست دستهبندیها">
|
||||
{categories.length === 0 ? (
|
||||
{error && (
|
||||
<p className={pageStyles.empty} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading || optionsLoading ? (
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={pageStyles.empty}>دستهبندیای وجود ندارد.</p>
|
||||
) : (
|
||||
<ul className={pageStyles.list}>
|
||||
@@ -129,8 +232,8 @@ export function CategoriesPage() {
|
||||
category={category}
|
||||
onAddChild={(parentId) => openCreateModal(parentId)}
|
||||
onEdit={openEditModal}
|
||||
onOpenOptions={setOptionsCategory}
|
||||
onRemove={handleRemove}
|
||||
onOpenOptions={(item) => void handleOpenOptions(item)}
|
||||
onRemove={(id) => void handleRemove(id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -154,18 +257,22 @@ export function CategoriesPage() {
|
||||
initialParentId={initialParentId}
|
||||
editingCategory={editingCategory}
|
||||
onClose={closeCategoryModal}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(values) => void handleSubmit(values)}
|
||||
/>
|
||||
|
||||
<CategoryOptionsModal
|
||||
open={Boolean(optionsCategory)}
|
||||
category={optionsCategory}
|
||||
initialOptions={
|
||||
open={Boolean(optionsCategory) && !optionsLoading}
|
||||
category={
|
||||
optionsCategory
|
||||
? getCategoryOptionsByName(optionsCategory.nameFa)
|
||||
: []
|
||||
? (findCategory(categories, optionsCategory.id) ??
|
||||
optionsCategory)
|
||||
: null
|
||||
}
|
||||
onClose={() => setOptionsCategory(null)}
|
||||
initialOptions={optionsInitial}
|
||||
onClose={() => {
|
||||
setOptionsCategory(null)
|
||||
setOptionsInitial([])
|
||||
}}
|
||||
onSave={handleSaveOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(140px, 1fr)
|
||||
minmax(200px, 2fr)
|
||||
minmax(110px, 0.9fr)
|
||||
auto;
|
||||
gap: 12px 14px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.55);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--brown);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.district {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.metaPhone {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.iconBtn::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
z-index: 6;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--brown-deeper);
|
||||
color: var(--text-on-dark);
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.iconBtn:hover::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 28px;
|
||||
z-index: 40;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
box-shadow: 0 12px 28px rgba(143, 65, 12, 0.3);
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.fab::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--brown-deeper);
|
||||
color: var(--text-on-dark);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import {
|
||||
AddressModal,
|
||||
type AddressFormValues,
|
||||
} from '../components/AddressModal'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { customerHeaderSections } from '../lib/nav'
|
||||
import {
|
||||
createMyAddress,
|
||||
deleteMyAddress,
|
||||
listMyAddresses,
|
||||
updateMyAddress,
|
||||
type UserAddress,
|
||||
} from '../lib/usersApi'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './CustomerAddressesPage.module.css'
|
||||
|
||||
export function CustomerAddressesPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [items, setItems] = useState<UserAddress[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<UserAddress | null>(null)
|
||||
|
||||
const handleAuthError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listMyAddresses()
|
||||
setItems(result)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setItems([])
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری آدرسها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
async function handleSubmit(values: AddressFormValues) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
district: values.district,
|
||||
address: values.address,
|
||||
landline: values.landline || undefined,
|
||||
}
|
||||
if (editing) {
|
||||
await updateMyAddress(editing.id, payload)
|
||||
} else {
|
||||
await createMyAddress(payload)
|
||||
}
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
throw err
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(address: UserAddress) {
|
||||
if (!window.confirm(`آدرس «${address.name}» حذف شود؟`)) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await deleteMyAddress(address.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'حذف آدرس ناموفق بود.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.page}`}>
|
||||
<Header sections={customerHeaderSections} variant="customer" />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.main}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
My Addresses
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>آدرسهای من</h1>
|
||||
<p className={styles.lead}>
|
||||
آدرسهای ارسال خود را مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.panel} role="alert">
|
||||
<p className={pageStyles.empty}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.listWrap}>
|
||||
{loading ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>هنوز آدرسی ثبت نکردهاید.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className={pageStyles.panel} aria-label="فهرست آدرسها">
|
||||
<ul className={pageStyles.list}>
|
||||
{items.map((address) => (
|
||||
<li key={address.id} className={pageStyles.row}>
|
||||
<div className={pageStyles.identity}>
|
||||
<span className={pageStyles.name}>{address.name}</span>
|
||||
<span className={pageStyles.district}>
|
||||
{address.district}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.meta}>
|
||||
<span className={pageStyles.metaLabel}>آدرس</span>
|
||||
<span className={pageStyles.metaValue}>
|
||||
{address.address}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.meta}>
|
||||
<span className={pageStyles.metaLabel}>تلفن ثابت</span>
|
||||
<span className={pageStyles.metaPhone} dir="ltr">
|
||||
{address.landline?.trim() || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(address)
|
||||
setModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDelete(address)}
|
||||
>
|
||||
<Trash2 size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.fab}
|
||||
aria-label="افزودن آدرس"
|
||||
data-tooltip="افزودن آدرس"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<AddressModal
|
||||
open={modalOpen}
|
||||
editing={editing}
|
||||
onClose={() => {
|
||||
if (busy) return
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { customerHeaderSections } from '../lib/nav'
|
||||
import { listMyDiscounts, type Discount } from '../lib/discountsApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './DiscountsPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
function formatExpireDate(iso: string) {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function statusLabel(discount: Discount) {
|
||||
if (!discount.active) return 'غیرفعال'
|
||||
if (discount.expired) return 'منقضی'
|
||||
return 'فعال'
|
||||
}
|
||||
|
||||
function statusClass(discount: Discount) {
|
||||
if (!discount.active) return pageStyles.statusInactive
|
||||
if (discount.expired) return pageStyles.statusExpired
|
||||
return pageStyles.statusActive
|
||||
}
|
||||
|
||||
export function CustomerDiscountsPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [items, setItems] = useState<Discount[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listMyDiscounts({ page, pageSize: PAGE_SIZE })
|
||||
setItems(result.items)
|
||||
setTotal(result.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری تخفیفها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.page}`}>
|
||||
<Header sections={customerHeaderSections} variant="customer" />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.main}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
My Discounts
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>تخفیفهای من</h1>
|
||||
<p className={styles.lead}>
|
||||
کدهای تخفیف اختصاصی شما برای سفارشهای بعدی.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.panel} role="alert">
|
||||
<p className={pageStyles.empty}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>هنوز کد تخفیفی برای شما ثبت نشده است.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className={pageStyles.customerGrid} aria-label="تخفیفهای من">
|
||||
{items.map((discount) => (
|
||||
<article key={discount.id} className={pageStyles.card}>
|
||||
<div className={pageStyles.cardShine} aria-hidden />
|
||||
<div className={pageStyles.cardBody}>
|
||||
<div className={pageStyles.codeBlock}>
|
||||
<span className={pageStyles.cardCode}>{discount.code}</span>
|
||||
<span className={`${pageStyles.badge} ${statusClass(discount)}`}>
|
||||
{statusLabel(discount)}
|
||||
</span>
|
||||
</div>
|
||||
<p className={pageStyles.cardPercent}>
|
||||
{formatPrice(discount.percent)}٪ تخفیف
|
||||
</p>
|
||||
<p className={pageStyles.cardLine}>
|
||||
سقف {formatPrice(discount.maxValue)} تومان
|
||||
{discount.minOrderAmount > 0
|
||||
? ` · حداقل سفارش ${formatPrice(discount.minOrderAmount)} تومان`
|
||||
: ''}
|
||||
</p>
|
||||
<p className={pageStyles.cardLine}>
|
||||
{discount.general || !discount.user
|
||||
? 'عمومی — همه کاربران'
|
||||
: 'اختصاصی شما'}
|
||||
</p>
|
||||
<p className={pageStyles.cardLine}>
|
||||
{discount.category
|
||||
? `دسته: ${discount.category.nameFa}`
|
||||
: 'قابل استفاده در همه دستهها'}
|
||||
</p>
|
||||
<p className={pageStyles.cardLine}>
|
||||
انقضا: {formatExpireDate(discount.expiresAt)}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && total > PAGE_SIZE && (
|
||||
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
<span className={pageStyles.pageInfo}>
|
||||
صفحه {formatPrice(page)} از {formatPrice(totalPages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
import { AnimatedTiles } from '../components/AnimatedTiles'
|
||||
import { getSession } from '../lib/auth'
|
||||
import { customerHeaderSections, customerNavSections } from '../lib/nav'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
export function CustomerHomePage() {
|
||||
const session = getSession()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header sections={customerHeaderSections} variant="customer" />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Balout Customer
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
سلام{session?.user.name ? `، ${session.user.name}` : ''}
|
||||
</p>
|
||||
<h1 className={styles.headline}>به حساب بلوط خوش آمدید</h1>
|
||||
<p className={styles.lead}>
|
||||
پروفایل، سفارشها، تخفیفها و آدرسهای خود را از اینجا مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AnimatedTiles tiles={customerNavSections} label="منوی مشتری" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Eye } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { OrderDetailsModal } from '../components/OrderDetailsModal'
|
||||
import {
|
||||
formatOrderDateTime,
|
||||
formatOrderQuantityByUnit,
|
||||
orderStatusLabel,
|
||||
type Order,
|
||||
} from '../data/orders'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { customerHeaderSections } from '../lib/nav'
|
||||
import { listMyOrders } from '../lib/ordersApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './OrdersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function CustomerOrdersPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [detailsOrder, setDetailsOrder] = useState<Order | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const handleAuthError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listMyOrders({ page, pageSize: PAGE_SIZE })
|
||||
setOrders(result.items)
|
||||
setTotal(result.total)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setOrders([])
|
||||
setTotal(0)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری سفارشها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders()
|
||||
}, [loadOrders])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.ordersPage}`}>
|
||||
<Header sections={customerHeaderSections} variant="customer" />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.ordersMain}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
My Orders
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>سفارشهای من</h1>
|
||||
<p className={styles.lead}>
|
||||
سفارشهای ثبتشده خود را مشاهده و پیگیری کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.panel} role="alert">
|
||||
<p className={pageStyles.empty}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.listWrap}>
|
||||
{loading ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>هنوز سفارشی ثبت نکردهاید.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className={pageStyles.panel} aria-label="فهرست سفارشها">
|
||||
<ul className={pageStyles.list}>
|
||||
{orders.map((order) => {
|
||||
const when = formatOrderDateTime(order.createdAt)
|
||||
const quantityLines = formatOrderQuantityByUnit(
|
||||
order.items ?? [],
|
||||
)
|
||||
return (
|
||||
<li
|
||||
key={order.id}
|
||||
className={`${pageStyles.row} ${pageStyles.customerRow}`}
|
||||
>
|
||||
<div className={pageStyles.orderId}>
|
||||
<span className={pageStyles.idValue} dir="ltr">
|
||||
{order.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.when}>
|
||||
<span className={pageStyles.date}>{when.date}</span>
|
||||
{when.time && (
|
||||
<span className={pageStyles.time} dir="ltr">
|
||||
{when.time}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>تعداد اقلام</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
{quantityLines.length > 0
|
||||
? quantityLines.map((line) => (
|
||||
<span
|
||||
key={line}
|
||||
className={pageStyles.statValueLine}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))
|
||||
: formatPrice(order.itemCount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>مبلغ کل</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
{formatPrice(order.totalPrice)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`${pageStyles.statusBadge} ${pageStyles[`status_${order.status}`]}`}
|
||||
>
|
||||
{orderStatusLabel[order.status]}
|
||||
</span>
|
||||
|
||||
<div className={pageStyles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="مشاهده جزئیات"
|
||||
data-tooltip="جزئیات"
|
||||
onClick={() => setDetailsOrder(order)}
|
||||
>
|
||||
<Eye size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && total > 0 && (
|
||||
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
||||
(pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={`${pageStyles.pageBtn} ${pageNumber === page ? pageStyles.pageBtnActive : ''}`}
|
||||
aria-current={pageNumber === page ? 'page' : undefined}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{formatPrice(pageNumber)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
|
||||
<span className={pageStyles.pageInfo}>
|
||||
{formatPrice(total)} سفارش
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<OrderDetailsModal
|
||||
open={Boolean(detailsOrder)}
|
||||
order={
|
||||
detailsOrder
|
||||
? (orders.find((item) => item.id === detailsOrder.id) ??
|
||||
detailsOrder)
|
||||
: null
|
||||
}
|
||||
onClose={() => setDetailsOrder(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
import { customerHeaderSections } from '../lib/nav'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
const titles: Record<string, { fa: string; en: string; lead: string }> = {
|
||||
'/profile': {
|
||||
fa: 'پروفایل من',
|
||||
en: 'My Profile',
|
||||
lead: 'مدیریت اطلاعات حساب بهزودی در دسترس قرار میگیرد.',
|
||||
},
|
||||
'/orders': {
|
||||
fa: 'سفارشهای من',
|
||||
en: 'My Orders',
|
||||
lead: 'فهرست سفارشهای شما بهزودی اینجا نمایش داده میشود.',
|
||||
},
|
||||
'/discounts': {
|
||||
fa: 'تخفیفهای من',
|
||||
en: 'My Discounts',
|
||||
lead: 'کدها و پیشنهادهای تخفیف بهزودی اضافه میشوند.',
|
||||
},
|
||||
'/addresses': {
|
||||
fa: 'آدرسهای من',
|
||||
en: 'My Addresses',
|
||||
lead: 'مدیریت آدرسهای ارسال بهزودی آماده میشود.',
|
||||
},
|
||||
}
|
||||
|
||||
export function CustomerSectionPage() {
|
||||
const { pathname, key } = useLocation()
|
||||
const title = titles[pathname] ?? {
|
||||
fa: 'به زودی',
|
||||
en: 'Coming Soon',
|
||||
lead: 'این بخش بهزودی آماده میشود.',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header sections={customerHeaderSections} variant="customer" />
|
||||
<main className={styles.main}>
|
||||
<section key={key} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
{title.en}
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>{title.fa}</h1>
|
||||
<p className={styles.lead}>{title.lead}</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
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.85);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--brown);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.filterBtn {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: var(--field-height);
|
||||
padding: 0 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterBtn:hover:not(:disabled) {
|
||||
background: var(--brown-dark);
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 8px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(140px, 1.1fr)
|
||||
minmax(140px, 1.2fr)
|
||||
minmax(160px, 1.4fr)
|
||||
minmax(140px, 1.2fr)
|
||||
auto;
|
||||
gap: 12px 14px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.55);
|
||||
}
|
||||
|
||||
.rowScoped {
|
||||
grid-template-columns:
|
||||
minmax(140px, 1.1fr)
|
||||
minmax(160px, 1.4fr)
|
||||
minmax(140px, 1.2fr)
|
||||
auto;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--font-en);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.statusActive {
|
||||
color: #2f6b4f;
|
||||
background: rgba(47, 107, 79, 0.08);
|
||||
border-color: rgba(47, 107, 79, 0.2);
|
||||
}
|
||||
|
||||
.statusExpired {
|
||||
color: #8a5a12;
|
||||
background: rgba(138, 90, 18, 0.08);
|
||||
border-color: rgba(138, 90, 18, 0.2);
|
||||
}
|
||||
|
||||
.statusInactive {
|
||||
color: #9b2c2c;
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
border-color: rgba(155, 44, 44, 0.2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
display: block;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.metaSub {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metaPhone {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
unicode-bidi: isolate;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.iconBtn::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
z-index: 6;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--brown-deeper);
|
||||
color: var(--text-on-dark);
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.iconBtn:hover::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||
background: rgba(255, 250, 250, 0.75);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
color: var(--brown);
|
||||
border-color: rgba(143, 65, 12, 0.28);
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 28px;
|
||||
z-index: 40;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
box-shadow: 0 12px 28px rgba(143, 65, 12, 0.3);
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.fab::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--brown-deeper);
|
||||
color: var(--text-on-dark);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.customerGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 20px 18px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
overflow: hidden;
|
||||
animation: fadeUp 0.45s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.cardShine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--glass-shine);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cardCode {
|
||||
font-family: var(--font-en);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
letter-spacing: 0.05em;
|
||||
direction: ltr;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.cardPercent {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cardLine {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.field {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import {
|
||||
DiscountModal,
|
||||
type DiscountFormValues,
|
||||
} from '../components/DiscountModal'
|
||||
import { PersianDateInput } from '../components/PersianDateInput'
|
||||
import { formatCellNumber, stripUserTitle, type User } from '../data/users'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import {
|
||||
createDiscount,
|
||||
deleteDiscount,
|
||||
listDiscounts,
|
||||
updateDiscount,
|
||||
type Discount,
|
||||
} from '../lib/discountsApi'
|
||||
import { getUser } from '../lib/usersApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './DiscountsPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
type Filters = {
|
||||
code: string
|
||||
expiresOn: string
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = {
|
||||
code: '',
|
||||
expiresOn: '',
|
||||
}
|
||||
|
||||
function formatExpireDate(iso: string) {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function statusLabel(discount: Discount) {
|
||||
if (!discount.active) return 'غیرفعال'
|
||||
if (discount.expired) return 'منقضی'
|
||||
return 'فعال'
|
||||
}
|
||||
|
||||
function statusClass(discount: Discount) {
|
||||
if (!discount.active) return pageStyles.statusInactive
|
||||
if (discount.expired) return pageStyles.statusExpired
|
||||
return pageStyles.statusActive
|
||||
}
|
||||
|
||||
function userDisplayName(user: User) {
|
||||
return (
|
||||
stripUserTitle(`${user.title} ${user.firstName} ${user.lastName}`) ||
|
||||
`${user.firstName} ${user.lastName}`.trim()
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscountsPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const scopedUserId = searchParams.get('userId')?.trim() || ''
|
||||
const isUserScoped = Boolean(scopedUserId)
|
||||
|
||||
const [items, setItems] = useState<Discount[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Discount | null>(null)
|
||||
const [scopedUser, setScopedUser] = useState<User | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setDraftFilters(emptyFilters)
|
||||
setAppliedFilters(emptyFilters)
|
||||
setEditing(null)
|
||||
setModalOpen(false)
|
||||
}, [scopedUserId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserScoped) {
|
||||
setScopedUser(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const user = await getUser(scopedUserId)
|
||||
if (!cancelled) setScopedUser(user)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setScopedUser(null)
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isUserScoped, scopedUserId, navigate])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listDiscounts({
|
||||
code: appliedFilters.code.trim() || undefined,
|
||||
expiresOn: appliedFilters.expiresOn || undefined,
|
||||
...(isUserScoped
|
||||
? { userId: scopedUserId }
|
||||
: { generalOnly: true }),
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setItems(result.items)
|
||||
setTotal(result.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری کدهای تخفیف ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedFilters, page, navigate, isUserScoped, scopedUserId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
async function handleSubmit(values: DiscountFormValues) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = isUserScoped
|
||||
? { ...values, userId: scopedUserId }
|
||||
: values
|
||||
if (editing) {
|
||||
await updateDiscount(editing.id, payload)
|
||||
} else {
|
||||
await createDiscount(payload)
|
||||
}
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
if (page !== 1 && !editing) {
|
||||
setPage(1)
|
||||
} else {
|
||||
await load()
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(discount: Discount) {
|
||||
if (!window.confirm(`کد «${discount.code}» حذف شود؟`)) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await deleteDiscount(discount.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'حذف کد تخفیف ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headline = isUserScoped
|
||||
? scopedUser
|
||||
? `تخفیفهای ${userDisplayName(scopedUser)}`
|
||||
: 'تخفیفهای کاربر'
|
||||
: 'کدهای تخفیف'
|
||||
|
||||
const lead = isUserScoped
|
||||
? scopedUser
|
||||
? `کدهای اختصاصی ${userDisplayName(scopedUser)} (${formatCellNumber(scopedUser.cellNumber)})`
|
||||
: 'فقط کدهای تخفیف اختصاصی این کاربر نمایش داده میشود.'
|
||||
: 'در اینجا فقط کد های تخفیف عمومی نمایش داده می شود. برای بررسی کد تخفیف یک کاربر از قسمت لیست کاربران، بر روی کد های تخفیف یک کاربر کلیک کنید.'
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.page}`}>
|
||||
<Header />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.main}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Discounts
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link
|
||||
to={isUserScoped ? '/users' : '/'}
|
||||
className={styles.backLink}
|
||||
>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
{isUserScoped ? 'بازگشت به کاربران' : 'بازگشت به صفحه اصلی'}
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>{headline}</h1>
|
||||
<p className={styles.lead}>{lead}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
className={pageStyles.filters}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setPage(1)
|
||||
setAppliedFilters({
|
||||
code: draftFilters.code.trim(),
|
||||
expiresOn: draftFilters.expiresOn,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label className={pageStyles.field}>
|
||||
<span>کد تخفیف</span>
|
||||
<input
|
||||
value={draftFilters.code}
|
||||
onChange={(e) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
code: e.target.value.toUpperCase(),
|
||||
}))
|
||||
}
|
||||
placeholder="مثلاً WELCOME10"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label className={pageStyles.field}>
|
||||
<span>تاریخ انقضا</span>
|
||||
<PersianDateInput
|
||||
value={draftFilters.expiresOn}
|
||||
onChange={(value) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
expiresOn: value,
|
||||
}))
|
||||
}
|
||||
placeholder="انتخاب تاریخ"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className={pageStyles.filterBtn} disabled={busy}>
|
||||
اعمال
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.panel} role="alert">
|
||||
<p className={pageStyles.empty}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.listWrap}>
|
||||
{loading ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>کد تخفیفی ثبت نشده است.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className={pageStyles.panel} aria-label="فهرست کدهای تخفیف">
|
||||
<ul className={pageStyles.list}>
|
||||
{items.map((discount) => (
|
||||
<li
|
||||
key={discount.id}
|
||||
className={`${pageStyles.row} ${isUserScoped ? pageStyles.rowScoped : ''}`}
|
||||
>
|
||||
<div className={pageStyles.codeBlock}>
|
||||
<span className={pageStyles.code} dir="ltr">
|
||||
{discount.code}
|
||||
</span>
|
||||
<span className={`${pageStyles.badge} ${statusClass(discount)}`}>
|
||||
{statusLabel(discount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isUserScoped && (
|
||||
<div className={pageStyles.meta}>
|
||||
<span className={pageStyles.metaLabel}>کاربر</span>
|
||||
{discount.user ? (
|
||||
<>
|
||||
<span className={pageStyles.metaValue}>
|
||||
{stripUserTitle(discount.user.name) ||
|
||||
`${discount.user.firstName} ${discount.user.lastName}`}
|
||||
</span>
|
||||
<span className={pageStyles.metaPhone} dir="ltr">
|
||||
{formatCellNumber(discount.user.cellNumber)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={pageStyles.metaValue}>عمومی</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.meta}>
|
||||
<span className={pageStyles.metaLabel}>تخفیف</span>
|
||||
<span className={pageStyles.metaValue}>
|
||||
{formatPrice(discount.percent)}٪ تا{' '}
|
||||
{formatPrice(discount.maxValue)} تومان
|
||||
</span>
|
||||
<span className={pageStyles.metaSub}>
|
||||
حداقل سفارش {formatPrice(discount.minOrderAmount)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.meta}>
|
||||
<span className={pageStyles.metaLabel}>دستهبندی</span>
|
||||
<span className={pageStyles.metaValue}>
|
||||
{discount.category?.nameFa ?? 'همه دستهها'}
|
||||
</span>
|
||||
<span className={pageStyles.metaSub}>
|
||||
انقضا {formatExpireDate(discount.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(discount)
|
||||
setModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDelete(discount)}
|
||||
>
|
||||
<Trash2 size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && total > 0 && (
|
||||
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page <= 1 || busy}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
<span className={pageStyles.pageInfo}>
|
||||
صفحه {formatPrice(page)} از {formatPrice(totalPages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page >= totalPages || busy}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.fab}
|
||||
aria-label="ثبت کد تخفیف"
|
||||
data-tooltip="ثبت کد تخفیف"
|
||||
disabled={busy || (isUserScoped && !scopedUser)}
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<DiscountModal
|
||||
open={modalOpen}
|
||||
editing={editing}
|
||||
lockedUser={isUserScoped ? scopedUser : null}
|
||||
onClose={() => {
|
||||
if (busy) return
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-width: 440px;
|
||||
padding: 40px 32px 28px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
@@ -73,6 +73,83 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
border: 1px solid rgba(143, 65, 12, 0.08);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab,
|
||||
.tabActive {
|
||||
height: 40px;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tab {
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab:hover:not(:disabled) {
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--brown);
|
||||
background: rgba(255, 250, 250, 0.95);
|
||||
box-shadow: 0 4px 12px rgba(143, 65, 12, 0.1);
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.otpActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.altLinks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--brown);
|
||||
padding: 4px 2px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.linkBtn:hover:not(:disabled) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.linkBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
@@ -221,4 +298,8 @@
|
||||
.logo {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+875
-67
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,29 @@
|
||||
background: rgba(255, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.customerRow {
|
||||
grid-template-columns:
|
||||
minmax(100px, 0.9fr)
|
||||
minmax(100px, 0.95fr)
|
||||
80px
|
||||
minmax(110px, 1.1fr)
|
||||
minmax(120px, 1.1fr)
|
||||
auto;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
justify-self: stretch;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||
background: rgba(143, 65, 12, 0.06);
|
||||
color: var(--brown);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.orderId,
|
||||
.when,
|
||||
.customer,
|
||||
@@ -124,15 +147,24 @@
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statValueLine {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusBtn {
|
||||
justify-self: stretch;
|
||||
padding: 8px 10px;
|
||||
@@ -363,12 +395,17 @@
|
||||
grid-template-columns: 1fr 1fr 1.2fr;
|
||||
}
|
||||
|
||||
.customerRow {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.stat:nth-of-type(1),
|
||||
.controls {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.statusBtn {
|
||||
.statusBtn,
|
||||
.statusBadge {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
+135
-39
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Eye, Plus } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import {
|
||||
buildOrderFromCreateValues,
|
||||
CreateOrderModal,
|
||||
type CreateOrderValues,
|
||||
} from '../components/CreateOrderModal'
|
||||
@@ -11,12 +10,20 @@ import { OrderDetailsModal } from '../components/OrderDetailsModal'
|
||||
import { OrderStatusModal } from '../components/OrderStatusModal'
|
||||
import {
|
||||
formatOrderDateTime,
|
||||
formatOrderQuantityByUnit,
|
||||
orderStatusLabel,
|
||||
orders as initialOrders,
|
||||
type Order,
|
||||
type OrderStatus,
|
||||
} from '../data/orders'
|
||||
import { formatCellNumber } from '../data/users'
|
||||
import { formatCellNumber, stripUserTitle } from '../data/users'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import {
|
||||
createOrder,
|
||||
listOrders,
|
||||
updateOrderStatus,
|
||||
} from '../lib/ordersApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './OrdersPage.module.css'
|
||||
@@ -25,42 +32,105 @@ const PAGE_SIZE = 10
|
||||
|
||||
export function OrdersPage() {
|
||||
const location = useLocation()
|
||||
const [orders, setOrders] = useState<Order[]>(initialOrders)
|
||||
const navigate = useNavigate()
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [statusOrder, setStatusOrder] = useState<Order | null>(null)
|
||||
const [detailsOrder, setDetailsOrder] = useState<Order | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(orders.length / PAGE_SIZE))
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const handleAuthError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listOrders({ page, pageSize: PAGE_SIZE })
|
||||
setOrders(result.items)
|
||||
setTotal(result.total)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setOrders([])
|
||||
setTotal(0)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری سفارشها ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders()
|
||||
}, [loadOrders])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
const pageOrders = useMemo(() => {
|
||||
const start = (page - 1) * PAGE_SIZE
|
||||
return orders.slice(start, start + PAGE_SIZE)
|
||||
}, [orders, page])
|
||||
|
||||
function handleStatusSubmit(status: OrderStatus) {
|
||||
async function handleStatusSubmit(status: OrderStatus) {
|
||||
if (!statusOrder) return
|
||||
setOrders((current) =>
|
||||
current.map((item) =>
|
||||
item.id === statusOrder.id ? { ...item, status } : item,
|
||||
),
|
||||
)
|
||||
setStatusOrder(null)
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const updated = await updateOrderStatus(statusOrder.id, status)
|
||||
setOrders((current) =>
|
||||
current.map((item) => (item.id === updated.id ? updated : item)),
|
||||
)
|
||||
setStatusOrder(null)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بهروزرسانی وضعیت ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateOrder(values: CreateOrderValues) {
|
||||
const maxNum = orders.reduce((max, order) => {
|
||||
const n = Number(order.id.replace(/\D/g, ''))
|
||||
return Number.isFinite(n) ? Math.max(max, n) : max
|
||||
}, 1399)
|
||||
const order = buildOrderFromCreateValues(values, `BL-${maxNum + 1}`)
|
||||
setOrders((current) => [order, ...current])
|
||||
setCreateOpen(false)
|
||||
setPage(1)
|
||||
async function handleCreateOrder(values: CreateOrderValues) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await createOrder({
|
||||
customerId: values.customerId,
|
||||
deliveryType: values.deliveryType,
|
||||
branchId: values.branchId,
|
||||
shippingAddressId: values.shippingAddressId,
|
||||
note: values.note || undefined,
|
||||
items: values.items,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setPage(1)
|
||||
if (page === 1) {
|
||||
await loadOrders()
|
||||
}
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
throw err
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -89,21 +159,34 @@ export function OrdersPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className={pageStyles.panel} role="alert">
|
||||
<p className={pageStyles.empty}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.listWrap}>
|
||||
{orders.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>سفارشی برای نمایش وجود ندارد.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className={pageStyles.panel} aria-label="فهرست سفارشها">
|
||||
<ul className={pageStyles.list}>
|
||||
{pageOrders.map((order) => {
|
||||
{orders.map((order) => {
|
||||
const when = formatOrderDateTime(order.createdAt)
|
||||
const quantityLines = formatOrderQuantityByUnit(
|
||||
order.items ?? [],
|
||||
)
|
||||
return (
|
||||
<li key={order.id} className={pageStyles.row}>
|
||||
<div className={pageStyles.orderId}>
|
||||
<span className={pageStyles.idValue} dir="ltr">
|
||||
{order.id}
|
||||
{order.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -118,7 +201,7 @@ export function OrdersPage() {
|
||||
|
||||
<div className={pageStyles.customer}>
|
||||
<span className={pageStyles.customerName}>
|
||||
{order.customerName}
|
||||
{stripUserTitle(order.customerName)}
|
||||
</span>
|
||||
<span className={pageStyles.customerPhone} dir="ltr">
|
||||
{formatCellNumber(order.customerPhone)}
|
||||
@@ -128,7 +211,16 @@ export function OrdersPage() {
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>تعداد اقلام</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
{formatPrice(order.itemCount)}
|
||||
{quantityLines.length > 0
|
||||
? quantityLines.map((line) => (
|
||||
<span
|
||||
key={line}
|
||||
className={pageStyles.statValueLine}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))
|
||||
: formatPrice(order.itemCount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +235,7 @@ export function OrdersPage() {
|
||||
type="button"
|
||||
className={`${pageStyles.statusBtn} ${pageStyles[`status_${order.status}`]}`}
|
||||
onClick={() => setStatusOrder(order)}
|
||||
disabled={busy}
|
||||
aria-label={`وضعیت: ${orderStatusLabel[order.status]} — تغییر وضعیت`}
|
||||
>
|
||||
{orderStatusLabel[order.status]}
|
||||
@@ -154,6 +247,7 @@ export function OrdersPage() {
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="مشاهده جزئیات"
|
||||
data-tooltip="جزئیات"
|
||||
disabled={busy}
|
||||
onClick={() => setDetailsOrder(order)}
|
||||
>
|
||||
<Eye size={17} strokeWidth={1.75} />
|
||||
@@ -167,12 +261,12 @@ export function OrdersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orders.length > 0 && (
|
||||
{!loading && total > 0 && (
|
||||
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page <= 1}
|
||||
disabled={page <= 1 || busy}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
قبلی
|
||||
@@ -185,6 +279,7 @@ export function OrdersPage() {
|
||||
type="button"
|
||||
className={`${pageStyles.pageBtn} ${pageNumber === page ? pageStyles.pageBtnActive : ''}`}
|
||||
aria-current={pageNumber === page ? 'page' : undefined}
|
||||
disabled={busy}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{formatPrice(pageNumber)}
|
||||
@@ -195,7 +290,7 @@ export function OrdersPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.pageBtn}
|
||||
disabled={page >= totalPages}
|
||||
disabled={page >= totalPages || busy}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
@@ -204,7 +299,7 @@ export function OrdersPage() {
|
||||
</button>
|
||||
|
||||
<span className={pageStyles.pageInfo}>
|
||||
{formatPrice(orders.length)} سفارش
|
||||
{formatPrice(total)} سفارش
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
@@ -214,6 +309,7 @@ export function OrdersPage() {
|
||||
className={pageStyles.fab}
|
||||
aria-label="ثبت سفارش جدید"
|
||||
data-tooltip="ثبت سفارش جدید"
|
||||
disabled={busy}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
@@ -228,10 +324,10 @@ export function OrdersPage() {
|
||||
|
||||
<OrderStatusModal
|
||||
open={Boolean(statusOrder)}
|
||||
orderId={statusOrder?.id ?? ''}
|
||||
orderCode={statusOrder?.code ?? ''}
|
||||
currentStatus={statusOrder?.status ?? 'pending'}
|
||||
onClose={() => setStatusOrder(null)}
|
||||
onSubmit={handleStatusSubmit}
|
||||
onSubmit={(status) => void handleStatusSubmit(status)}
|
||||
/>
|
||||
|
||||
<OrderDetailsModal
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.9fr) 1.2fr;
|
||||
gap: 22px;
|
||||
align-items: start;
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.mediaColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mainImage {
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-mid);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.mainImage img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gallery li {
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||
background: rgba(255, 250, 250, 0.85);
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.infoColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px 22px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
color: var(--brown);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.priceAmount {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.75;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0 0 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tags li {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 243, 243, 0.9);
|
||||
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.editBtn:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description :is(p, ul, ol, div) {
|
||||
margin: 0 0 0.65em;
|
||||
}
|
||||
|
||||
.description :is(p, ul, ol, div):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.description :is(ul, ol) {
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
|
||||
.optionGroups {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.optionGroup {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.optionFlavor {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.optionList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.optionList li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.72);
|
||||
border: 1px solid rgba(143, 65, 12, 0.1);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
animation: fadeUp 0.5s var(--ease-out) both;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gallery {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ChevronRight, Pencil } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import {
|
||||
formatPriceParts,
|
||||
productImageSrc,
|
||||
type Product,
|
||||
} from '../data/products'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import { getProduct } from '../lib/productsApi'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './ProductDetailsPage.module.css'
|
||||
|
||||
const sellUnitLabel = {
|
||||
unit: 'واحد',
|
||||
kilo: 'کیلو',
|
||||
} as const
|
||||
|
||||
export function ProductDetailsPage() {
|
||||
const { productId } = useParams<{ productId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return
|
||||
|
||||
void (async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await getProduct(productId)
|
||||
setProduct({
|
||||
...result,
|
||||
options: result.options ?? [],
|
||||
gallery: result.gallery ?? [],
|
||||
tags: result.tags ?? [],
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setProduct(null)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری محصول ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [productId, navigate])
|
||||
|
||||
const price = product ? formatPriceParts(product) : null
|
||||
const groupedOptions = product
|
||||
? Object.entries(
|
||||
(product.options ?? []).reduce<
|
||||
Record<string, { label: string; entries: Product['options'] }>
|
||||
>((acc, option) => {
|
||||
const key = option.flavorId
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
label: option.flavor?.nameFa ?? option.flavorId,
|
||||
entries: [],
|
||||
}
|
||||
}
|
||||
acc[key].entries.push(option)
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
: []
|
||||
|
||||
const descriptionHtml = product?.description?.trim() ?? ''
|
||||
const hasDescription =
|
||||
descriptionHtml.length > 0 &&
|
||||
descriptionHtml !== '<br>' &&
|
||||
descriptionHtml !== '<div><br></div>'
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Product Details
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/products/list" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به فهرست محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>
|
||||
{product?.nameFa ?? 'جزئیات محصول'}
|
||||
</h1>
|
||||
<p className={styles.lead}>
|
||||
{product?.nameEn
|
||||
? product.nameEn
|
||||
: 'اطلاعات کامل محصول را مشاهده کنید.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||
) : error ? (
|
||||
<p className={pageStyles.empty} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : !product || !price ? (
|
||||
<p className={pageStyles.empty}>محصول یافت نشد.</p>
|
||||
) : (
|
||||
<div className={pageStyles.layout}>
|
||||
<div className={pageStyles.mediaColumn}>
|
||||
<div className={pageStyles.mainImage}>
|
||||
<img
|
||||
src={productImageSrc(product)}
|
||||
alt={product.nameFa}
|
||||
/>
|
||||
</div>
|
||||
{(product.gallery?.length ?? 0) > 0 && (
|
||||
<ul className={pageStyles.gallery} aria-label="گالری تصاویر">
|
||||
{product.gallery!.map((item) => (
|
||||
<li key={item.id ?? item.storageKey}>
|
||||
<img src={item.url} alt="" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.infoColumn}>
|
||||
<div className={pageStyles.panel}>
|
||||
<div className={pageStyles.metaRow}>
|
||||
<span className={pageStyles.category}>
|
||||
{product.category?.nameFa ?? 'بدون دسته'}
|
||||
</span>
|
||||
<p className={pageStyles.price}>
|
||||
<span className={pageStyles.priceAmount}>
|
||||
{price.amount}
|
||||
</span>
|
||||
<span className={pageStyles.priceLabel}>
|
||||
تومان — {sellUnitLabel[product.sellUnit]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{product.intro?.trim() && (
|
||||
<p className={pageStyles.intro}>{product.intro.trim()}</p>
|
||||
)}
|
||||
|
||||
{(product.tags?.length ?? 0) > 0 && (
|
||||
<ul className={pageStyles.tags} aria-label="تگها">
|
||||
{product.tags!.map((tag) => (
|
||||
<li key={tag}>{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.editBtn}
|
||||
onClick={() => navigate(`/products/${product.id}/edit`)}
|
||||
>
|
||||
<Pencil size={16} strokeWidth={1.75} />
|
||||
ویرایش محصول
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDescription && (
|
||||
<section className={pageStyles.panel}>
|
||||
<h2 className={pageStyles.sectionTitle}>توضیحات</h2>
|
||||
<div
|
||||
className={pageStyles.description}
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{groupedOptions.length > 0 && (
|
||||
<section className={pageStyles.panel}>
|
||||
<h2 className={pageStyles.sectionTitle}>آپشنها</h2>
|
||||
<ul className={pageStyles.optionGroups}>
|
||||
{groupedOptions.map(([flavorId, group]) => (
|
||||
<li key={flavorId} className={pageStyles.optionGroup}>
|
||||
<h3 className={pageStyles.optionFlavor}>
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className={pageStyles.optionList}>
|
||||
{group.entries.map((option) => (
|
||||
<li key={option.id}>
|
||||
<span>{option.amount}</span>
|
||||
<span>
|
||||
{formatPrice(option.price)} تومان
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+153
-48
@@ -1,16 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Plus } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ProductCard } from '../components/ProductCard'
|
||||
import { PriceInput } from '../components/PriceInput'
|
||||
import { ProductOptionsModal } from '../components/ProductOptionsModal'
|
||||
import {
|
||||
productCategories,
|
||||
products as initialProducts,
|
||||
type Product,
|
||||
type ProductOptionValue,
|
||||
} from '../data/products'
|
||||
flattenCategories,
|
||||
type Category,
|
||||
} from '../data/categories'
|
||||
import type { Product, ProductOptionValue } from '../data/products'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import { listCategories } from '../lib/categoriesApi'
|
||||
import {
|
||||
deleteProduct,
|
||||
listProducts,
|
||||
toOptionPayload,
|
||||
updateProduct,
|
||||
} from '../lib/productsApi'
|
||||
import styles from './HomePage.module.css'
|
||||
import listStyles from './ProductsListPage.module.css'
|
||||
|
||||
@@ -18,49 +27,108 @@ type Filters = {
|
||||
name: string
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
category: string
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = {
|
||||
name: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
category: '',
|
||||
categoryId: '',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 48
|
||||
|
||||
export function ProductsListPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [products, setProducts] = useState<Product[]>(initialProducts)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => flattenCategories(categories),
|
||||
[categories],
|
||||
)
|
||||
|
||||
const handleAuthError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const min = appliedFilters.minPrice
|
||||
? Number(appliedFilters.minPrice)
|
||||
: undefined
|
||||
const max = appliedFilters.maxPrice
|
||||
? Number(appliedFilters.maxPrice)
|
||||
: undefined
|
||||
|
||||
const result = await listProducts({
|
||||
q: appliedFilters.name.trim() || undefined,
|
||||
categoryId: appliedFilters.categoryId || undefined,
|
||||
minPrice:
|
||||
min !== undefined && !Number.isNaN(min) ? min : undefined,
|
||||
maxPrice:
|
||||
max !== undefined && !Number.isNaN(max) ? max : undefined,
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setProducts(
|
||||
result.items.map((item) => ({
|
||||
...item,
|
||||
options: item.options ?? [],
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setProducts([])
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری محصولات ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedFilters, handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
void loadProducts()
|
||||
}, [loadProducts])
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const tree = await listCategories()
|
||||
setCategories(tree)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
}
|
||||
})()
|
||||
}, [handleAuthError])
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(false)
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [location.key])
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
const nameQuery = appliedFilters.name.trim().toLowerCase()
|
||||
const min = appliedFilters.minPrice ? Number(appliedFilters.minPrice) : null
|
||||
const max = appliedFilters.maxPrice ? Number(appliedFilters.maxPrice) : null
|
||||
|
||||
return products.filter((product) => {
|
||||
const matchesName =
|
||||
!nameQuery ||
|
||||
product.nameFa.includes(appliedFilters.name.trim()) ||
|
||||
product.nameEn.toLowerCase().includes(nameQuery)
|
||||
|
||||
const matchesMin = min === null || Number.isNaN(min) || product.price >= min
|
||||
const matchesMax = max === null || Number.isNaN(max) || product.price <= max
|
||||
const matchesCategory =
|
||||
!appliedFilters.category || product.category === appliedFilters.category
|
||||
|
||||
return matchesName && matchesMin && matchesMax && matchesCategory
|
||||
})
|
||||
}, [products, appliedFilters])
|
||||
}, [location.key, products])
|
||||
|
||||
function updateDraft<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||
setDraftFilters((current) => ({ ...current, [key]: value }))
|
||||
@@ -69,25 +137,52 @@ export function ProductsListPage() {
|
||||
function handleApplyFilters(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setAppliedFilters(draftFilters)
|
||||
setVisible(false)
|
||||
window.requestAnimationFrame(() => setVisible(true))
|
||||
}
|
||||
|
||||
function handleRemove(product: Product) {
|
||||
setProducts((current) => current.filter((item) => item.id !== product.id))
|
||||
}
|
||||
|
||||
function handleEdit(product: Product) {
|
||||
window.alert(`ویرایش «${product.nameFa}» بهزودی فعال میشود.`)
|
||||
navigate(`/products/${product.id}/edit`)
|
||||
}
|
||||
|
||||
function handleSaveOptions(
|
||||
function handleView(product: Product) {
|
||||
navigate(`/products/${product.id}`)
|
||||
}
|
||||
|
||||
async function handleRemove(product: Product) {
|
||||
const confirmed = window.confirm(
|
||||
`محصول «${product.nameFa}» حذف شود؟`,
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setBusyId(product.id)
|
||||
setError('')
|
||||
try {
|
||||
await deleteProduct(product.id)
|
||||
setProducts((current) =>
|
||||
current.filter((item) => item.id !== product.id),
|
||||
)
|
||||
if (optionsProduct?.id === product.id) setOptionsProduct(null)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'حذف محصول ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveOptions(
|
||||
productId: string,
|
||||
options: ProductOptionValue[],
|
||||
) {
|
||||
const updated = await updateProduct(productId, {
|
||||
options: toOptionPayload(options),
|
||||
})
|
||||
setProducts((current) =>
|
||||
current.map((product) =>
|
||||
product.id === productId ? { ...product, options } : product,
|
||||
product.id === productId
|
||||
? { ...updated, options: updated.options ?? [] }
|
||||
: product,
|
||||
),
|
||||
)
|
||||
setOptionsProduct(null)
|
||||
@@ -153,13 +248,13 @@ export function ProductsListPage() {
|
||||
<label className={listStyles.field}>
|
||||
<span>دستهبندی</span>
|
||||
<select
|
||||
value={draftFilters.category}
|
||||
onChange={(e) => updateDraft('category', e.target.value)}
|
||||
value={draftFilters.categoryId}
|
||||
onChange={(e) => updateDraft('categoryId', e.target.value)}
|
||||
>
|
||||
<option value="">همه دستهها</option>
|
||||
{productCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
{categoryOptions.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.labelFa}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -172,16 +267,26 @@ export function ProductsListPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{filteredProducts.length === 0 ? (
|
||||
{error && (
|
||||
<p className={listStyles.empty} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className={listStyles.empty}>در حال بارگذاری...</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className={listStyles.empty}>محصولی برای نمایش وجود ندارد.</p>
|
||||
) : (
|
||||
<section className={listStyles.grid} aria-label="فهرست محصولات">
|
||||
{filteredProducts.map((product, index) => (
|
||||
{products.map((product, index) => (
|
||||
<ProductCard
|
||||
key={`${location.key}-${product.id}-${index}`}
|
||||
key={`${location.key}-${product.id}`}
|
||||
product={product}
|
||||
index={index}
|
||||
visible={visible}
|
||||
busy={busyId === product.id}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onOpenOptions={setOptionsProduct}
|
||||
onRemove={handleRemove}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
.profileMain {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.formShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 28px 26px 24px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
overflow: hidden;
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.formShine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--glass-shine);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.formHeader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-en);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--brown);
|
||||
opacity: 0.45;
|
||||
margin-bottom: 4px;
|
||||
direction: ltr;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.formTitle {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.18);
|
||||
background: rgba(143, 65, 12, 0.07);
|
||||
color: var(--brown);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field 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);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.field select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select: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);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field input:read-only,
|
||||
.field select:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field input[dir='ltr'] {
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.colTitle {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.colFirst {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.colLast {
|
||||
grid-column: span 5;
|
||||
}
|
||||
|
||||
.colPhone,
|
||||
.colCategory {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.1);
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
}
|
||||
|
||||
.primaryBtn:hover:not(:disabled) {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
color: var(--brown);
|
||||
border: 1px solid rgba(143, 65, 12, 0.2);
|
||||
background: rgba(255, 250, 250, 0.7);
|
||||
}
|
||||
|
||||
.secondaryBtn:hover:not(:disabled) {
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.primaryBtn:disabled,
|
||||
.secondaryBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.error,
|
||||
.success,
|
||||
.statusText {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #9b2c2c;
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
border: 1px solid rgba(155, 44, 44, 0.16);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #2f6b4f;
|
||||
background: rgba(47, 107, 79, 0.08);
|
||||
border: 1px solid rgba(47, 107, 79, 0.16);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.colTitle,
|
||||
.colFirst,
|
||||
.colLast,
|
||||
.colPhone,
|
||||
.colCategory {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, KeyRound, Save } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ChangePasswordModal } from '../components/ChangePasswordModal'
|
||||
import {
|
||||
userCategories,
|
||||
userRoleLabel,
|
||||
userTitles,
|
||||
formatCellNumber,
|
||||
type User,
|
||||
type UserCategory,
|
||||
type UserTitle,
|
||||
} from '../data/users'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
clearSession,
|
||||
getSession,
|
||||
patchSessionUser,
|
||||
} from '../lib/auth'
|
||||
import { getAppKind, redirectToLogin } from '../lib/host'
|
||||
import { customerHeaderSections } from '../lib/nav'
|
||||
import {
|
||||
getMyProfile,
|
||||
updateMyProfile,
|
||||
updateUserPassword,
|
||||
} from '../lib/usersApi'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './ProfilePage.module.css'
|
||||
|
||||
export function ProfilePage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const formId = useId()
|
||||
const isCustomer = getAppKind() === 'customer'
|
||||
const session = getSession()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||
const [headerTick, setHeaderTick] = useState(0)
|
||||
|
||||
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||
const [cellNumber, setCellNumber] = useState('')
|
||||
const [roleLabel, setRoleLabel] = useState('')
|
||||
|
||||
function applyUser(user: User) {
|
||||
setTitle(user.title)
|
||||
setFirstName(user.firstName)
|
||||
setLastName(user.lastName)
|
||||
setCategory(user.category ?? '')
|
||||
setCellNumber(user.cellNumber)
|
||||
setRoleLabel(userRoleLabel[user.role] ?? user.role)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const user = await getMyProfile()
|
||||
if (cancelled) return
|
||||
applyUser(user)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
const fallback = getSession()?.user
|
||||
if (fallback) {
|
||||
setTitle(fallback.title as UserTitle)
|
||||
setFirstName(fallback.firstName)
|
||||
setLastName(fallback.lastName)
|
||||
setCellNumber(fallback.cellNumber)
|
||||
setRoleLabel(userRoleLabel[fallback.role] ?? fallback.role)
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری پروفایل ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (!firstName.trim() || !lastName.trim()) {
|
||||
setError('نام و نام خانوادگی الزامی است')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await updateMyProfile({
|
||||
title,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
category: category || null,
|
||||
})
|
||||
applyUser(updated)
|
||||
patchSessionUser({
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
firstName: updated.firstName,
|
||||
lastName: updated.lastName,
|
||||
cellNumber: updated.cellNumber,
|
||||
role: updated.role,
|
||||
name: updated.name,
|
||||
})
|
||||
setHeaderTick((tick) => tick + 1)
|
||||
setSuccess('اطلاعات پروفایل ذخیره شد.')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'ذخیره پروفایل ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(password: string) {
|
||||
if (!session) return
|
||||
await updateUserPassword(session.user.id, password)
|
||||
setPasswordOpen(false)
|
||||
setSuccess('رمز عبور با موفقیت تغییر کرد.')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header
|
||||
key={headerTick}
|
||||
sections={isCustomer ? customerHeaderSections : undefined}
|
||||
variant={isCustomer ? 'customer' : 'admin'}
|
||||
/>
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.profileMain}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
My Profile
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>پروفایل من</h1>
|
||||
<p className={styles.lead}>
|
||||
اطلاعات حساب کاربری خود را مشاهده و ویرایش کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className={pageStyles.formShell}>
|
||||
<p className={pageStyles.statusText}>در حال بارگذاری...</p>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
className={pageStyles.formShell}
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
noValidate
|
||||
>
|
||||
<div className={pageStyles.formShine} aria-hidden />
|
||||
|
||||
<div className={pageStyles.formHeader}>
|
||||
<div>
|
||||
<p className={pageStyles.eyebrow}>Account</p>
|
||||
<h2 className={pageStyles.formTitle}>اطلاعات شخصی</h2>
|
||||
</div>
|
||||
{roleLabel && (
|
||||
<span className={pageStyles.roleBadge}>{roleLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(error || success) && (
|
||||
<div
|
||||
className={error ? pageStyles.error : pageStyles.success}
|
||||
role="status"
|
||||
>
|
||||
{error || success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={pageStyles.grid}>
|
||||
<div className={`${pageStyles.field} ${pageStyles.colTitle}`}>
|
||||
<label htmlFor={`${formId}-title`}>عنوان</label>
|
||||
<select
|
||||
id={`${formId}-title`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||
disabled={saving}
|
||||
>
|
||||
{userTitles.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${pageStyles.field} ${pageStyles.colFirst}`}>
|
||||
<label htmlFor={`${formId}-first`}>نام</label>
|
||||
<input
|
||||
id={`${formId}-first`}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={saving}
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${pageStyles.field} ${pageStyles.colLast}`}>
|
||||
<label htmlFor={`${formId}-last`}>نام خانوادگی</label>
|
||||
<input
|
||||
id={`${formId}-last`}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={saving}
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${pageStyles.field} ${pageStyles.colPhone}`}>
|
||||
<label htmlFor={`${formId}-phone`}>شماره موبایل</label>
|
||||
<input
|
||||
id={`${formId}-phone`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
value={formatCellNumber(cellNumber) || cellNumber}
|
||||
readOnly
|
||||
disabled
|
||||
aria-readonly="true"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${pageStyles.field} ${pageStyles.colCategory}`}>
|
||||
<label htmlFor={`${formId}-category`}>دستهبندی</label>
|
||||
<select
|
||||
id={`${formId}-category`}
|
||||
value={category}
|
||||
onChange={(e) =>
|
||||
setCategory((e.target.value || '') as UserCategory | '')
|
||||
}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">بدون دستهبندی</option>
|
||||
{userCategories.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.secondaryBtn}
|
||||
onClick={() => setPasswordOpen(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
<KeyRound size={18} strokeWidth={1.75} />
|
||||
تغییر رمز عبور
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={pageStyles.primaryBtn}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={18} strokeWidth={1.75} />
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ChangePasswordModal
|
||||
open={passwordOpen}
|
||||
userName={`${firstName} ${lastName}`.trim()}
|
||||
onClose={() => setPasswordOpen(false)}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,6 +267,17 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
border: 1px solid rgba(155, 44, 44, 0.18);
|
||||
color: #9b2c2c;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 22px;
|
||||
@@ -280,6 +291,12 @@
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.saveBtn:hover {
|
||||
background: color-mix(in srgb, var(--brown) 88%, #000);
|
||||
}
|
||||
|
||||
+443
-225
@@ -1,40 +1,57 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useId, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||
import { districts } from '../data/districts'
|
||||
import { districts as fallbackDistricts } from '../data/districts'
|
||||
import { Header } from '../components/Header'
|
||||
import { PriceInput } from '../components/PriceInput'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import {
|
||||
createBranch,
|
||||
listBranches,
|
||||
listDistricts,
|
||||
listShippingExceptions,
|
||||
removeBranch,
|
||||
replaceShippingExceptions,
|
||||
updateBranch as patchBranch,
|
||||
type Branch as ApiBranch,
|
||||
} from '../lib/settingsApi'
|
||||
import { parsePriceNumber, toEnglishDigits } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './SettingsPage.module.css'
|
||||
|
||||
type ShippingException = {
|
||||
type ShippingExceptionRow = {
|
||||
id: string
|
||||
district: string
|
||||
price: string
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
type Branch = {
|
||||
type BranchRow = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function createId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
function createException(): ShippingException {
|
||||
function createException(): ShippingExceptionRow {
|
||||
return {
|
||||
id: createId(),
|
||||
district: '',
|
||||
price: '',
|
||||
isNew: true,
|
||||
}
|
||||
}
|
||||
|
||||
function createBranch(): Branch {
|
||||
function createBranchRow(): BranchRow {
|
||||
return {
|
||||
id: createId(),
|
||||
name: '',
|
||||
@@ -42,23 +59,98 @@ function createBranch(): Branch {
|
||||
address: '',
|
||||
landline: '',
|
||||
cellNumber: '',
|
||||
isNew: true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapBranch(branch: ApiBranch): BranchRow {
|
||||
return {
|
||||
id: branch.id,
|
||||
name: branch.name,
|
||||
district: branch.district,
|
||||
address: branch.address,
|
||||
landline: branch.landline ?? '',
|
||||
cellNumber: branch.cellNumber ?? '',
|
||||
isNew: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const formId = useId()
|
||||
const [exceptions, setExceptions] = useState<ShippingException[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [districtOptions, setDistrictOptions] =
|
||||
useState<string[]>(fallbackDistricts)
|
||||
const [exceptions, setExceptions] = useState<ShippingExceptionRow[]>([])
|
||||
const [branches, setBranches] = useState<BranchRow[]>([])
|
||||
const [initialBranchIds, setInitialBranchIds] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [savedMessage, setSavedMessage] = useState('')
|
||||
|
||||
const handleAuthError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
redirectToLogin()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const loadSettings = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!options.silent) setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [districtList, shippingList, branchList] = await Promise.all([
|
||||
listDistricts().catch(() => fallbackDistricts),
|
||||
listShippingExceptions(),
|
||||
listBranches(),
|
||||
])
|
||||
setDistrictOptions(
|
||||
districtList.length > 0 ? districtList : fallbackDistricts,
|
||||
)
|
||||
setExceptions(
|
||||
shippingList.map((item) => ({
|
||||
id: item.id,
|
||||
district: item.district,
|
||||
price: String(item.price),
|
||||
isNew: false,
|
||||
})),
|
||||
)
|
||||
const mappedBranches = branchList.map(mapBranch)
|
||||
setBranches(mappedBranches)
|
||||
setInitialBranchIds(mappedBranches.map((item) => item.id))
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری تنظیمات ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
if (!options.silent) setLoading(false)
|
||||
}
|
||||
},
|
||||
[handleAuthError],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
function touch() {
|
||||
setSavedMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
function updateException(
|
||||
id: string,
|
||||
patch: Partial<Pick<ShippingException, 'district' | 'price'>>,
|
||||
patch: Partial<Pick<ShippingExceptionRow, 'district' | 'price'>>,
|
||||
) {
|
||||
setExceptions((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
@@ -66,16 +158,104 @@ export function SettingsPage() {
|
||||
touch()
|
||||
}
|
||||
|
||||
function updateBranch(id: string, patch: Partial<Omit<Branch, 'id'>>) {
|
||||
function updateBranchRow(id: string, patch: Partial<Omit<BranchRow, 'id'>>) {
|
||||
setBranches((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
)
|
||||
touch()
|
||||
}
|
||||
|
||||
function handleSave(event: React.FormEvent) {
|
||||
async function handleSave(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setSavedMessage('تنظیمات ذخیره شد (نسخه نمایشی)')
|
||||
setError('')
|
||||
setSavedMessage('')
|
||||
|
||||
for (const item of exceptions) {
|
||||
if (!item.district.trim()) {
|
||||
setError('منطقه هزینه ارسال را انتخاب کنید')
|
||||
return
|
||||
}
|
||||
if (parsePriceNumber(item.price) === null) {
|
||||
setError(`قیمت منطقه «${item.district}» معتبر نیست`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const districtSet = new Set<string>()
|
||||
for (const item of exceptions) {
|
||||
if (districtSet.has(item.district)) {
|
||||
setError(`منطقه تکراری در هزینه ارسال: ${item.district}`)
|
||||
return
|
||||
}
|
||||
districtSet.add(item.district)
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
if (!branch.name.trim()) {
|
||||
setError('نام شعبه الزامی است')
|
||||
return
|
||||
}
|
||||
if (!branch.district.trim()) {
|
||||
setError(`منطقه شعبه «${branch.name}» را انتخاب کنید`)
|
||||
return
|
||||
}
|
||||
if (!branch.address.trim()) {
|
||||
setError(`آدرس شعبه «${branch.name}» الزامی است`)
|
||||
return
|
||||
}
|
||||
const cell = toEnglishDigits(branch.cellNumber).replace(/\D/g, '')
|
||||
if (cell && !/^09\d{9}$/.test(cell)) {
|
||||
setError(`شماره موبایل شعبه «${branch.name}» معتبر نیست`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await replaceShippingExceptions({
|
||||
exceptions: exceptions.map((item) => ({
|
||||
district: item.district,
|
||||
price: parsePriceNumber(item.price) ?? 0,
|
||||
})),
|
||||
})
|
||||
|
||||
const currentServerIds = new Set(
|
||||
branches.filter((item) => !item.isNew).map((item) => item.id),
|
||||
)
|
||||
|
||||
for (const id of initialBranchIds) {
|
||||
if (!currentServerIds.has(id)) {
|
||||
await removeBranch(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
const cell = toEnglishDigits(branch.cellNumber).replace(/\D/g, '')
|
||||
const payload = {
|
||||
name: branch.name.trim(),
|
||||
district: branch.district,
|
||||
address: branch.address.trim(),
|
||||
landline: toEnglishDigits(branch.landline).trim() || undefined,
|
||||
cellNumber: cell || undefined,
|
||||
}
|
||||
|
||||
if (branch.isNew) {
|
||||
await createBranch(payload)
|
||||
} else {
|
||||
await patchBranch(branch.id, payload)
|
||||
}
|
||||
}
|
||||
|
||||
await loadSettings({ silent: true })
|
||||
setSavedMessage('تنظیمات با موفقیت ذخیره شد')
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'ذخیره تنظیمات ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,149 +284,69 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
id={formId}
|
||||
className={pageStyles.form}
|
||||
onSubmit={handleSave}
|
||||
aria-label="تنظیمات فروشگاه"
|
||||
>
|
||||
<section className={pageStyles.panel} aria-labelledby="shipping-title">
|
||||
<div className={pageStyles.section}>
|
||||
<h2 id="shipping-title" className={pageStyles.sectionTitle}>
|
||||
هزینه ارسال
|
||||
</h2>
|
||||
|
||||
<p className={pageStyles.hint}>
|
||||
تمام ارسالها به سرتاسر قم رایگان میباشد مگر شما منطقهای را در
|
||||
زیر مستثنی کنید
|
||||
{loading ? (
|
||||
<p className={pageStyles.savedMessage}>در حال بارگذاری...</p>
|
||||
) : (
|
||||
<form
|
||||
id={formId}
|
||||
className={pageStyles.form}
|
||||
onSubmit={(event) => void handleSave(event)}
|
||||
aria-label="تنظیمات فروشگاه"
|
||||
>
|
||||
{error && (
|
||||
<p className={pageStyles.errorMessage} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className={pageStyles.exceptionList}>
|
||||
{exceptions.map((item, index) => (
|
||||
<li key={item.id} className={pageStyles.exceptionCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
onClick={() => {
|
||||
setExceptions((current) =>
|
||||
current.filter((row) => row.id !== item.id),
|
||||
)
|
||||
touch()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
<section
|
||||
className={pageStyles.panel}
|
||||
aria-labelledby="shipping-title"
|
||||
>
|
||||
<div className={pageStyles.section}>
|
||||
<h2 id="shipping-title" className={pageStyles.sectionTitle}>
|
||||
هزینه ارسال
|
||||
</h2>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`ship-district-${item.id}`}>منطقه</label>
|
||||
<select
|
||||
id={`ship-district-${item.id}`}
|
||||
value={item.district}
|
||||
onChange={(e) =>
|
||||
updateException(item.id, {
|
||||
district: e.target.value,
|
||||
})
|
||||
}
|
||||
autoFocus={index === exceptions.length - 1}
|
||||
<p className={pageStyles.hint}>
|
||||
تمام ارسالها به سرتاسر قم رایگان میباشد مگر شما منطقهای را
|
||||
در زیر مستثنی کنید
|
||||
</p>
|
||||
|
||||
<ul className={pageStyles.exceptionList}>
|
||||
{exceptions.map((item, index) => (
|
||||
<li key={item.id} className={pageStyles.exceptionCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setExceptions((current) =>
|
||||
current.filter((row) => row.id !== item.id),
|
||||
)
|
||||
touch()
|
||||
}}
|
||||
>
|
||||
<option value="">انتخاب منطقه</option>
|
||||
{districts.map((district) => (
|
||||
<option key={district} value={district}>
|
||||
{district}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`price-${item.id}`}>قیمت</label>
|
||||
<PriceInput
|
||||
id={`price-${item.id}`}
|
||||
value={item.price}
|
||||
onChange={(digits) =>
|
||||
updateException(item.id, { price: digits })
|
||||
}
|
||||
placeholder="۰"
|
||||
aria-label={`قیمت منطقه ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={pageStyles.addCardItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.addCard}
|
||||
onClick={() => {
|
||||
setExceptions((current) => [...current, createException()])
|
||||
touch()
|
||||
}}
|
||||
aria-label="افزودن منطقه"
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
<span>افزودن منطقه</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={pageStyles.panel} aria-labelledby="branches-title">
|
||||
<div className={pageStyles.section}>
|
||||
<h2 id="branches-title" className={pageStyles.sectionTitle}>
|
||||
شعبهها
|
||||
</h2>
|
||||
|
||||
<ul className={pageStyles.branchList}>
|
||||
{branches.map((branch, index) => (
|
||||
<li key={branch.id} className={pageStyles.branchCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||
aria-label="حذف شعبه"
|
||||
data-tooltip="حذف"
|
||||
onClick={() => {
|
||||
setBranches((current) =>
|
||||
current.filter((row) => row.id !== branch.id),
|
||||
)
|
||||
touch()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<div className={pageStyles.branchGrid}>
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-name-${branch.id}`}>نام</label>
|
||||
<input
|
||||
id={`branch-name-${branch.id}`}
|
||||
type="text"
|
||||
placeholder="مثال: شعبه مرکزی"
|
||||
value={branch.name}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, { name: e.target.value })
|
||||
}
|
||||
autoFocus={index === branches.length - 1}
|
||||
/>
|
||||
</div>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-district-${branch.id}`}>
|
||||
منطقه
|
||||
</label>
|
||||
<label htmlFor={`ship-district-${item.id}`}>منطقه</label>
|
||||
<select
|
||||
id={`branch-district-${branch.id}`}
|
||||
value={branch.district}
|
||||
id={`ship-district-${item.id}`}
|
||||
value={item.district}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, {
|
||||
updateException(item.id, {
|
||||
district: e.target.value,
|
||||
})
|
||||
}
|
||||
autoFocus={index === exceptions.length - 1}
|
||||
>
|
||||
<option value="">انتخاب منطقه</option>
|
||||
{districts.map((district) => (
|
||||
{districtOptions.map((district) => (
|
||||
<option key={district} value={district}>
|
||||
{district}
|
||||
</option>
|
||||
@@ -254,95 +354,213 @@ export function SettingsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${pageStyles.field} ${pageStyles.branchAddress}`}
|
||||
>
|
||||
<label htmlFor={`branch-address-${branch.id}`}>
|
||||
آدرس
|
||||
</label>
|
||||
<input
|
||||
id={`branch-address-${branch.id}`}
|
||||
type="text"
|
||||
placeholder="آدرس کامل شعبه"
|
||||
value={branch.address}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-landline-${branch.id}`}>
|
||||
تلفن ثابت
|
||||
</label>
|
||||
<input
|
||||
id={`branch-landline-${branch.id}`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
placeholder="02531234567"
|
||||
value={branch.landline}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, {
|
||||
landline: e.target.value,
|
||||
})
|
||||
<label htmlFor={`price-${item.id}`}>قیمت</label>
|
||||
<PriceInput
|
||||
id={`price-${item.id}`}
|
||||
value={item.price}
|
||||
onChange={(digits) =>
|
||||
updateException(item.id, { price: digits })
|
||||
}
|
||||
placeholder="۰"
|
||||
aria-label={`قیمت منطقه ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-cell-${branch.id}`}>
|
||||
شماره موبایل
|
||||
</label>
|
||||
<input
|
||||
id={`branch-cell-${branch.id}`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
placeholder="09121234567"
|
||||
value={branch.cellNumber}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, {
|
||||
cellNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<li className={pageStyles.addCardItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.addCard}
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setExceptions((current) => [
|
||||
...current,
|
||||
createException(),
|
||||
])
|
||||
touch()
|
||||
}}
|
||||
aria-label="افزودن منطقه"
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
<span>افزودن منطقه</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<li className={pageStyles.branchAddItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.addCard}
|
||||
onClick={() => {
|
||||
setBranches((current) => [...current, createBranch()])
|
||||
touch()
|
||||
}}
|
||||
aria-label="افزودن شعبه"
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
<span>افزودن شعبه</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<section
|
||||
className={pageStyles.panel}
|
||||
aria-labelledby="branches-title"
|
||||
>
|
||||
<div className={pageStyles.section}>
|
||||
<h2 id="branches-title" className={pageStyles.sectionTitle}>
|
||||
شعبهها
|
||||
</h2>
|
||||
|
||||
<ul className={pageStyles.branchList}>
|
||||
{branches.map((branch, index) => (
|
||||
<li key={branch.id} className={pageStyles.branchCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||
aria-label="حذف شعبه"
|
||||
data-tooltip="حذف"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setBranches((current) =>
|
||||
current.filter((row) => row.id !== branch.id),
|
||||
)
|
||||
touch()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<div className={pageStyles.branchGrid}>
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-name-${branch.id}`}>نام</label>
|
||||
<input
|
||||
id={`branch-name-${branch.id}`}
|
||||
type="text"
|
||||
placeholder="مثال: شعبه مرکزی"
|
||||
value={branch.name}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBranchRow(branch.id, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
autoFocus={index === branches.length - 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-district-${branch.id}`}>
|
||||
منطقه
|
||||
</label>
|
||||
<select
|
||||
id={`branch-district-${branch.id}`}
|
||||
value={branch.district}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBranchRow(branch.id, {
|
||||
district: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">انتخاب منطقه</option>
|
||||
{districtOptions.map((district) => (
|
||||
<option key={district} value={district}>
|
||||
{district}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${pageStyles.field} ${pageStyles.branchAddress}`}
|
||||
>
|
||||
<label htmlFor={`branch-address-${branch.id}`}>
|
||||
آدرس
|
||||
</label>
|
||||
<input
|
||||
id={`branch-address-${branch.id}`}
|
||||
type="text"
|
||||
placeholder="آدرس کامل شعبه"
|
||||
value={branch.address}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBranchRow(branch.id, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-landline-${branch.id}`}>
|
||||
تلفن ثابت
|
||||
</label>
|
||||
<input
|
||||
id={`branch-landline-${branch.id}`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
placeholder="02531234567"
|
||||
value={branch.landline}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBranchRow(branch.id, {
|
||||
landline: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-cell-${branch.id}`}>
|
||||
شماره موبایل
|
||||
</label>
|
||||
<input
|
||||
id={`branch-cell-${branch.id}`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
placeholder="09121234567"
|
||||
value={branch.cellNumber}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
updateBranchRow(branch.id, {
|
||||
cellNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={pageStyles.branchAddItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.addCard}
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setBranches((current) => [
|
||||
...current,
|
||||
createBranchRow(),
|
||||
])
|
||||
touch()
|
||||
}}
|
||||
aria-label="افزودن شعبه"
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
<span>افزودن شعبه</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={pageStyles.footer}>
|
||||
{savedMessage && (
|
||||
<p className={pageStyles.savedMessage} role="status">
|
||||
{savedMessage}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className={pageStyles.saveBtn}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={pageStyles.footer}>
|
||||
{savedMessage && (
|
||||
<p className={pageStyles.savedMessage} role="status">
|
||||
{savedMessage}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className={pageStyles.saveBtn}>
|
||||
ذخیره تنظیمات
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1.4fr) minmax(120px, 1fr) 90px minmax(120px, 1.1fr) auto;
|
||||
grid-template-columns: minmax(180px, 1.5fr) 90px minmax(120px, 1.1fr) auto;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
@@ -188,11 +188,13 @@
|
||||
}
|
||||
|
||||
.cell {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
direction: ltr;
|
||||
text-align: end;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stat {
|
||||
@@ -200,6 +202,7 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
@@ -212,6 +215,9 @@
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -475,10 +481,6 @@
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cell {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+19
-5
@@ -6,6 +6,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
TicketPercent,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
@@ -14,6 +15,7 @@ import { RoleModal } from '../components/RoleModal'
|
||||
import { UserModal, type UserFormValues } from '../components/UserModal'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import { redirectToLogin } from '../lib/host'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
@@ -89,7 +91,7 @@ export function UsersPage() {
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
setUsers([])
|
||||
@@ -346,15 +348,14 @@ export function UsersPage() {
|
||||
<span className={pageStyles.name}>
|
||||
{user.title} {user.firstName} {user.lastName}
|
||||
</span>
|
||||
<span className={pageStyles.cell} dir="ltr">
|
||||
{formatCellNumber(user.cellNumber)}
|
||||
</span>
|
||||
{user.category && (
|
||||
<span className={pageStyles.chip}>{user.category}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={pageStyles.cell} dir="ltr">
|
||||
{formatCellNumber(user.cellNumber)}
|
||||
</span>
|
||||
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>سفارشها</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
@@ -396,6 +397,19 @@ export function UsersPage() {
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="کدهای تخفیف"
|
||||
data-tooltip="کدهای تخفیف"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() =>
|
||||
navigate(`/discounts?userId=${encodeURIComponent(user.id)}`)
|
||||
}
|
||||
>
|
||||
<TicketPercent size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
|
||||
@@ -4,4 +4,14 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
allowedHosts: [
|
||||
'baloutpastry.com',
|
||||
'admin.baloutpastry.com',
|
||||
'customer.baloutpastry.com',
|
||||
'localhost',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user