Wire dashboards to the API and add customer portal plus discounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:16 +03:30
co-authored by Cursor
parent af11d96fd0
commit b103e330ef
73 changed files with 8720 additions and 1605 deletions
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+215
View File
@@ -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;
}
}
+234
View File
@@ -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>
)
}
+189
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+247
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
+418
View File
@@ -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;
}
}
+453
View File
@@ -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>
)
}
+82 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -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
View File
@@ -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
+265
View File
@@ -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;
}
}
+222
View File
@@ -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
View File
@@ -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}
+265
View File
@@ -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;
}
}
+318
View File
@@ -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>
)
}
+17
View File
@@ -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
View File
@@ -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>
)
+10 -8
View File
@@ -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
View File
@@ -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}