Initial commit of Balout Pastry admin dashboards.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
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;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 22px 22px;
|
||||
}
|
||||
|
||||
.sectionDivider {
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.12);
|
||||
}
|
||||
|
||||
.sectionBand {
|
||||
border-top: 1px solid rgba(143, 65, 12, 0.12);
|
||||
border-bottom: 1px solid rgba(143, 65, 12, 0.12);
|
||||
}
|
||||
|
||||
.topGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.85fr) 1.4fr;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: 14px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||
background: rgba(255, 250, 250, 0.88);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
height: var(--field-height);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.field select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: left 12px center;
|
||||
padding-inline-end: 12px;
|
||||
padding-inline-start: 36px;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea: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);
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.priceRow .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
inset-inline-start: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchWrap input {
|
||||
padding-inline: 38px 36px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline: 0;
|
||||
z-index: 20;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fffdfc;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.14);
|
||||
}
|
||||
|
||||
.option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.option:hover,
|
||||
.optionActive {
|
||||
background: rgba(143, 65, 12, 0.07);
|
||||
}
|
||||
|
||||
.optionFa {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.optionEn {
|
||||
font-family: var(--font-en);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
direction: ltr;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.emptyOption {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.85rem;
|
||||
color: #8b2e2e;
|
||||
background: rgba(180, 60, 60, 0.08);
|
||||
border: 1px solid rgba(180, 60, 60, 0.22);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.submitBtn {
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 250, 250, 0.7);
|
||||
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.section {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.submitBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight, ChevronDown, Search } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
import { ImageGallery } from '../components/ImageGallery'
|
||||
import { PriceInput } from '../components/PriceInput'
|
||||
import { RichTextArea } from '../components/RichTextArea'
|
||||
import { TagInput } from '../components/TagInput'
|
||||
import {
|
||||
findCategory,
|
||||
flattenCategories,
|
||||
initialCategories,
|
||||
} from '../data/categories'
|
||||
import type { SellUnit } from '../data/products'
|
||||
import { parsePriceNumber } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './AddProductPage.module.css'
|
||||
|
||||
export function AddProductPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const formId = useId()
|
||||
|
||||
const [mainImage, setMainImage] = useState<string | null>(null)
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||
const [nameFa, setNameFa] = useState('')
|
||||
const [nameEn, setNameEn] = useState('')
|
||||
const [sellUnit, setSellUnit] = useState<SellUnit>('unit')
|
||||
const [price, setPrice] = useState('')
|
||||
const [intro, setIntro] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [gallery, setGallery] = useState<string[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [categoryQuery, setCategoryQuery] = useState('')
|
||||
const [categoryOpen, setCategoryOpen] = useState(false)
|
||||
const categoryRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => flattenCategories(initialCategories),
|
||||
[],
|
||||
)
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const q = categoryQuery.trim().toLowerCase()
|
||||
if (!q) return categoryOptions
|
||||
return categoryOptions.filter(
|
||||
(option) =>
|
||||
option.labelFa.includes(categoryQuery.trim()) ||
|
||||
option.labelEn.toLowerCase().includes(q),
|
||||
)
|
||||
}, [categoryOptions, categoryQuery])
|
||||
|
||||
const selectedCategory = categoryId
|
||||
? findCategory(initialCategories, categoryId)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!categoryRef.current?.contains(event.target as Node)) {
|
||||
setCategoryOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!mainImage) {
|
||||
setError('تصویر اصلی الزامی است')
|
||||
return
|
||||
}
|
||||
if (!categoryId) {
|
||||
setError('انتخاب دستهبندی الزامی است')
|
||||
return
|
||||
}
|
||||
if (!nameFa.trim() || !nameEn.trim()) {
|
||||
setError('نام فارسی و انگلیسی الزامی است')
|
||||
return
|
||||
}
|
||||
const numericPrice = parsePriceNumber(price)
|
||||
if (numericPrice === null || numericPrice <= 0) {
|
||||
setError('قیمت معتبر الزامی است')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
window.alert('محصول با موفقیت ثبت شد (نسخه نمایشی)')
|
||||
navigate('/products/list')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
New Product
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/products" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>ثبت محصول جدید</h1>
|
||||
<p className={styles.lead}>
|
||||
اطلاعات محصول را تکمیل کنید و ذخیره نمایید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
className={pageStyles.form}
|
||||
onSubmit={handleSubmit}
|
||||
aria-labelledby={`${formId}-title`}
|
||||
>
|
||||
<h2 id={`${formId}-title`} className={pageStyles.srOnly}>
|
||||
فرم ثبت محصول
|
||||
</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>
|
||||
</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')}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button type="submit" className={pageStyles.submitBtn}>
|
||||
ذخیره محصول
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.panel {
|
||||
position: relative;
|
||||
padding: 12px 12px 72px;
|
||||
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);
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 36px 16px;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 14px;
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
box-shadow: 0 10px 22px rgba(143, 65, 12, 0.28);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 28px rgba(143, 65, 12, 0.34);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after,
|
||||
.fab:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronRight, Plus } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { CategoryItem } from '../components/CategoryItem'
|
||||
import {
|
||||
CategoryModal,
|
||||
type CategoryFormValues,
|
||||
} from '../components/CategoryModal'
|
||||
import { CategoryOptionsModal } from '../components/CategoryOptionsModal'
|
||||
import {
|
||||
addChildCategory,
|
||||
createCategory,
|
||||
findCategory,
|
||||
initialCategories,
|
||||
removeCategory,
|
||||
updateCategory,
|
||||
type Category,
|
||||
} from '../data/categories'
|
||||
import type { CategoryFlavorOptions } from '../data/flavors'
|
||||
import {
|
||||
getCategoryOptionsByName,
|
||||
removeCategoryOptionsByName,
|
||||
setCategoryOptionsByName,
|
||||
} from '../lib/categoryOptionsStore'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './CategoriesPage.module.css'
|
||||
|
||||
export function CategoriesPage() {
|
||||
const location = useLocation()
|
||||
const [categories, setCategories] = useState<Category[]>(initialCategories)
|
||||
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)
|
||||
|
||||
function openCreateModal(parentId: string | null = null) {
|
||||
setEditingCategory(null)
|
||||
setInitialParentId(parentId)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setInitialParentId(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function closeCategoryModal() {
|
||||
setModalOpen(false)
|
||||
setEditingCategory(null)
|
||||
setInitialParentId(null)
|
||||
}
|
||||
|
||||
function handleSubmit(values: CategoryFormValues) {
|
||||
if (values.id) {
|
||||
const previous = findCategory(categories, values.id)
|
||||
setCategories((current) =>
|
||||
updateCategory(current, 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 {
|
||||
const category = createCategory(values.nameFa, values.nameEn)
|
||||
setCategories((current) => {
|
||||
if (!values.parentId) return [...current, category]
|
||||
return addChildCategory(current, values.parentId, category)
|
||||
})
|
||||
}
|
||||
closeCategoryModal()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function handleSaveOptions(
|
||||
_categoryId: string,
|
||||
options: CategoryFlavorOptions,
|
||||
) {
|
||||
if (!optionsCategory) return
|
||||
setCategoryOptionsByName(optionsCategory.nameFa, options)
|
||||
setOptionsCategory(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Categories
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/products" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>دسته بندی محصولات</h1>
|
||||
<p className={styles.lead}>
|
||||
دستهبندیهای والد و فرزند را مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={pageStyles.panel} aria-label="فهرست دستهبندیها">
|
||||
{categories.length === 0 ? (
|
||||
<p className={pageStyles.empty}>دستهبندیای وجود ندارد.</p>
|
||||
) : (
|
||||
<ul className={pageStyles.list}>
|
||||
{categories.map((category) => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
onAddChild={(parentId) => openCreateModal(parentId)}
|
||||
onEdit={openEditModal}
|
||||
onOpenOptions={setOptionsCategory}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.fab}
|
||||
onClick={() => openCreateModal(null)}
|
||||
aria-label="افزودن دستهبندی"
|
||||
data-tooltip="افزودن دستهبندی"
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
categories={categories}
|
||||
initialParentId={initialParentId}
|
||||
editingCategory={editingCategory}
|
||||
onClose={closeCategoryModal}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<CategoryOptionsModal
|
||||
open={Boolean(optionsCategory)}
|
||||
category={optionsCategory}
|
||||
initialOptions={
|
||||
optionsCategory
|
||||
? getCategoryOptionsByName(optionsCategory.nameFa)
|
||||
: []
|
||||
}
|
||||
onClose={() => setOptionsCategory(null)}
|
||||
onSave={handleSaveOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
padding-block: 12px 4px;
|
||||
animation: fadeUp 0.65s var(--ease-out) both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.enBackdrop {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-52%);
|
||||
z-index: 0;
|
||||
font-family: var(--font-en);
|
||||
font-size: clamp(2.6rem, 8vw, 5.2rem);
|
||||
font-weight: 400;
|
||||
line-height: 0.95;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--brown);
|
||||
opacity: 0.08;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.welcomeContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.35rem);
|
||||
font-weight: 700;
|
||||
color: var(--brown);
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 34rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.tileVisible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tileVisible:hover {
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.tileVisible:hover .tileSurface {
|
||||
border-color: rgba(143, 65, 12, 0.28);
|
||||
box-shadow: 0 20px 44px rgba(143, 65, 12, 0.14);
|
||||
}
|
||||
|
||||
.tileSurface {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 124px;
|
||||
padding: 22px 20px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(22px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
transition:
|
||||
border-color 0.35s var(--ease-out),
|
||||
box-shadow 0.35s var(--ease-out);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.tileVisible:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tileSurface {
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.tile:focus-visible .tileSurface {
|
||||
outline: 2px solid var(--brown);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.tileShine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--glass-shine);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tileIcon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.tileBody {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tileEn {
|
||||
position: absolute;
|
||||
inset-inline-end: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 0;
|
||||
font-family: var(--font-en);
|
||||
font-size: clamp(1.8rem, 3.2vw, 2.6rem);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--brown);
|
||||
opacity: 0.09;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.tileTitle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.tileDesc {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tileArrow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-primary);
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
transform 0.3s var(--ease-out),
|
||||
opacity 0.3s;
|
||||
}
|
||||
|
||||
.tile:hover .tileArrow {
|
||||
transform: translateX(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 28px 16px 48px;
|
||||
}
|
||||
|
||||
.tileSurface {
|
||||
min-height: 104px;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
}
|
||||
@@ -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 { navSections } from '../lib/nav'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
export function HomePage() {
|
||||
const session = getSession()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Balout Pastry Dashboard
|
||||
</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={navSections} label="منوی اصلی" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ambient {
|
||||
position: absolute;
|
||||
inset: -25%;
|
||||
background:
|
||||
radial-gradient(circle at 22% 28%, rgba(255, 255, 255, 0.95), transparent 42%),
|
||||
radial-gradient(circle at 78% 72%, rgba(143, 65, 12, 0.16), transparent 46%),
|
||||
radial-gradient(circle at 55% 12%, rgba(251, 243, 243, 0.9), transparent 40%);
|
||||
animation: softGlow 8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 40px 32px 28px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(32px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(1.35);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow:
|
||||
var(--glass-shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.85),
|
||||
inset 0 -1px 0 rgba(143, 65, 12, 0.04);
|
||||
overflow: hidden;
|
||||
animation: fadeUp 0.7s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.cardShine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--glass-shine);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 132px;
|
||||
height: auto;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brandTag {
|
||||
margin-top: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.intro {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
inset-inline-start: 14px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inputWrap input {
|
||||
width: 100%;
|
||||
height: var(--field-height);
|
||||
padding-inline: 44px 44px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 250, 250, 0.78);
|
||||
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.inputWrap input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.75;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.inputWrap input[dir='ltr'] {
|
||||
font-family: var(--font-en);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--brown);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.togglePassword {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
display: flex;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.togglePassword:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
margin-top: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
box-shadow: 0 10px 22px rgba(143, 65, 12, 0.28);
|
||||
transition:
|
||||
transform 0.25s var(--ease-out),
|
||||
background 0.2s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.submitBtn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background: var(--brown-dark);
|
||||
box-shadow: 0 14px 28px rgba(143, 65, 12, 0.34);
|
||||
}
|
||||
|
||||
.submitBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: #8b2e2e;
|
||||
background: rgba(180, 60, 60, 0.08);
|
||||
border: 1px solid rgba(180, 60, 60, 0.22);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 22px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 32px 22px 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, Smartphone } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { login } from '../lib/authApi'
|
||||
import { toEnglishDigits } from '../utils/price'
|
||||
import logo from '../images/Logo-balout-256.png'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
function normalizeCellNumber(value: string) {
|
||||
return toEnglishDigits(value).replace(/\D/g, '')
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const cellNumber = normalizeCellNumber(phone)
|
||||
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||
setError('لطفاً شماره موبایل معتبر وارد کنید (مثال: 09121234567)')
|
||||
return
|
||||
}
|
||||
if (password.length < 4) {
|
||||
setError('رمز عبور باید حداقل ۴ کاراکتر باشد')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await login(cellNumber, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'ورود ناموفق بود. اتصال به سرور را بررسی کنید.'
|
||||
setError(message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.ambient} aria-hidden />
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardShine} aria-hidden />
|
||||
|
||||
<div className={styles.brand}>
|
||||
<img src={logo} alt="بلوط" className={styles.logo} />
|
||||
<p className={styles.brandTag}>شیرینیفروشی بلوط</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.intro}>
|
||||
<h1 className={styles.title}>ورود به پنل</h1>
|
||||
<p className={styles.subtitle}>برای مدیریت فروشگاه وارد شوید</p>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit} autoComplete="off">
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-phone">شماره موبایل</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
placeholder="09xxxxxxxxx"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
autoComplete="off"
|
||||
name="login-phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-password">رمز عبور</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
name="login-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'در حال ورود...' : 'ورود'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
.ordersPage {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.ordersMain {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.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);
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(100px, 0.9fr)
|
||||
minmax(100px, 0.95fr)
|
||||
minmax(140px, 1.3fr)
|
||||
80px
|
||||
minmax(110px, 1.1fr)
|
||||
minmax(120px, 1.1fr)
|
||||
auto;
|
||||
gap: 12px 14px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.55);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.orderId,
|
||||
.when,
|
||||
.customer,
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.orderId {
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.when,
|
||||
.customer {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.idLabel,
|
||||
.statLabel {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.idValue {
|
||||
font-family: var(--font-en);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.2;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
unicode-bidi: isolate;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.customerName {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerPhone {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statusBtn {
|
||||
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;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
color 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.statusBtn:hover {
|
||||
border-color: rgba(143, 65, 12, 0.32);
|
||||
background: rgba(143, 65, 12, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.status_pending {
|
||||
color: #8a5a12;
|
||||
border-color: rgba(138, 90, 18, 0.22);
|
||||
background: rgba(138, 90, 18, 0.08);
|
||||
}
|
||||
|
||||
.status_confirmed {
|
||||
color: #2f6b4f;
|
||||
border-color: rgba(47, 107, 79, 0.22);
|
||||
background: rgba(47, 107, 79, 0.08);
|
||||
}
|
||||
|
||||
.status_preparing {
|
||||
color: #6b4a1f;
|
||||
border-color: rgba(107, 74, 31, 0.22);
|
||||
background: rgba(107, 74, 31, 0.08);
|
||||
}
|
||||
|
||||
.status_ready {
|
||||
color: #1f5f8a;
|
||||
border-color: rgba(31, 95, 138, 0.22);
|
||||
background: rgba(31, 95, 138, 0.08);
|
||||
}
|
||||
|
||||
.status_delivered {
|
||||
color: #3d5a40;
|
||||
border-color: rgba(61, 90, 64, 0.22);
|
||||
background: rgba(61, 90, 64, 0.1);
|
||||
}
|
||||
|
||||
.status_cancelled {
|
||||
color: #9b2c2c;
|
||||
border-color: rgba(155, 44, 44, 0.22);
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.iconBtn:hover::after,
|
||||
.iconBtn:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
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;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
color: var(--brown);
|
||||
border-color: rgba(143, 65, 12, 0.28);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pageBtnActive {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
border-color: var(--brown);
|
||||
}
|
||||
|
||||
.pageBtnActive:hover:not(:disabled) {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown-dark);
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-inline: 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
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);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 32px rgba(143, 65, 12, 0.36);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after,
|
||||
.fab:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr 1.2fr;
|
||||
}
|
||||
|
||||
.stat:nth-of-type(1),
|
||||
.controls {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.statusBtn {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.iconBtn::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronRight, Eye, Plus } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import {
|
||||
buildOrderFromCreateValues,
|
||||
CreateOrderModal,
|
||||
type CreateOrderValues,
|
||||
} from '../components/CreateOrderModal'
|
||||
import { OrderDetailsModal } from '../components/OrderDetailsModal'
|
||||
import { OrderStatusModal } from '../components/OrderStatusModal'
|
||||
import {
|
||||
formatOrderDateTime,
|
||||
orderStatusLabel,
|
||||
orders as initialOrders,
|
||||
type Order,
|
||||
type OrderStatus,
|
||||
} from '../data/orders'
|
||||
import { formatCellNumber } from '../data/users'
|
||||
import { formatPrice } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './OrdersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function OrdersPage() {
|
||||
const location = useLocation()
|
||||
const [orders, setOrders] = useState<Order[]>(initialOrders)
|
||||
const [page, setPage] = useState(1)
|
||||
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))
|
||||
|
||||
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) {
|
||||
if (!statusOrder) return
|
||||
setOrders((current) =>
|
||||
current.map((item) =>
|
||||
item.id === statusOrder.id ? { ...item, status } : item,
|
||||
),
|
||||
)
|
||||
setStatusOrder(null)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.ordersPage}`}>
|
||||
<Header />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.ordersMain}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
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>
|
||||
|
||||
<div className={pageStyles.listWrap}>
|
||||
{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) => {
|
||||
const when = formatOrderDateTime(order.createdAt)
|
||||
return (
|
||||
<li key={order.id} className={pageStyles.row}>
|
||||
<div className={pageStyles.orderId}>
|
||||
<span className={pageStyles.idValue} dir="ltr">
|
||||
{order.id}
|
||||
</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.customer}>
|
||||
<span className={pageStyles.customerName}>
|
||||
{order.customerName}
|
||||
</span>
|
||||
<span className={pageStyles.customerPhone} dir="ltr">
|
||||
{formatCellNumber(order.customerPhone)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>تعداد اقلام</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
{formatPrice(order.itemCount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>مبلغ کل</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
{formatPrice(order.totalPrice)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.statusBtn} ${pageStyles[`status_${order.status}`]}`}
|
||||
onClick={() => setStatusOrder(order)}
|
||||
aria-label={`وضعیت: ${orderStatusLabel[order.status]} — تغییر وضعیت`}
|
||||
>
|
||||
{orderStatusLabel[order.status]}
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
{orders.length > 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(orders.length)} سفارش
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.fab}
|
||||
aria-label="ثبت سفارش جدید"
|
||||
data-tooltip="ثبت سفارش جدید"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<CreateOrderModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreateOrder}
|
||||
/>
|
||||
|
||||
<OrderStatusModal
|
||||
open={Boolean(statusOrder)}
|
||||
orderId={statusOrder?.id ?? ''}
|
||||
currentStatus={statusOrder?.status ?? 'pending'}
|
||||
onClose={() => setStatusOrder(null)}
|
||||
onSubmit={handleStatusSubmit}
|
||||
/>
|
||||
|
||||
<OrderDetailsModal
|
||||
open={Boolean(detailsOrder)}
|
||||
order={
|
||||
detailsOrder
|
||||
? (orders.find((item) => item.id === detailsOrder.id) ??
|
||||
detailsOrder)
|
||||
: null
|
||||
}
|
||||
onClose={() => setDetailsOrder(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
const titles: Record<string, { fa: string; en: string }> = {
|
||||
'/products/list': { fa: 'محصولات من', en: 'My Products' },
|
||||
'/products/new': { fa: 'ثبت محصول جدید', en: 'New Product' },
|
||||
'/products/categories': { fa: 'دسته بندی محصولات', en: 'Categories' },
|
||||
}
|
||||
|
||||
export function PlaceholderPage() {
|
||||
const { pathname, key } = useLocation()
|
||||
const title = titles[pathname] ?? { fa: 'به زودی', en: 'Coming Soon' }
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
<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="/products" className={styles.backLink}>
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>{title.fa}</h1>
|
||||
<p className={styles.lead}>این بخش بهزودی آماده میشود.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1.3fr 1fr auto;
|
||||
align-items: end;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
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);
|
||||
animation: fadeUp 0.5s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.priceFields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field span {
|
||||
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.85);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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 select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: left 12px center;
|
||||
padding-inline-end: 12px;
|
||||
padding-inline-start: 36px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.applyBtn:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 32px rgba(143, 65, 12, 0.36);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after,
|
||||
.fab:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.priceFields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation } 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'
|
||||
import styles from './HomePage.module.css'
|
||||
import listStyles from './ProductsListPage.module.css'
|
||||
|
||||
type Filters = {
|
||||
name: string
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = {
|
||||
name: '',
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
category: '',
|
||||
}
|
||||
|
||||
export function ProductsListPage() {
|
||||
const location = useLocation()
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [products, setProducts] = useState<Product[]>(initialProducts)
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
||||
|
||||
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])
|
||||
|
||||
function updateDraft<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||
setDraftFilters((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
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}» بهزودی فعال میشود.`)
|
||||
}
|
||||
|
||||
function handleSaveOptions(
|
||||
productId: string,
|
||||
options: ProductOptionValue[],
|
||||
) {
|
||||
setProducts((current) =>
|
||||
current.map((product) =>
|
||||
product.id === productId ? { ...product, options } : product,
|
||||
),
|
||||
)
|
||||
setOptionsProduct(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
My Products
|
||||
</span>
|
||||
<div className={styles.welcomeContent}>
|
||||
<p className={styles.greeting}>
|
||||
<Link to="/products" className={styles.backLink}>
|
||||
<ChevronRight size={18} strokeWidth={1.75} />
|
||||
بازگشت به محصولات
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className={styles.headline}>محصولات من</h1>
|
||||
<p className={styles.lead}>
|
||||
فهرست محصولات فروشگاه را مشاهده و مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form
|
||||
className={listStyles.filters}
|
||||
onSubmit={handleApplyFilters}
|
||||
aria-label="فیلتر محصولات"
|
||||
>
|
||||
<label className={listStyles.field}>
|
||||
<span>نام</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی نام محصول"
|
||||
value={draftFilters.name}
|
||||
onChange={(e) => updateDraft('name', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={listStyles.priceFields}>
|
||||
<label className={listStyles.field}>
|
||||
<span>حداقل قیمت</span>
|
||||
<PriceInput
|
||||
placeholder="از"
|
||||
value={draftFilters.minPrice}
|
||||
onChange={(value) => updateDraft('minPrice', value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={listStyles.field}>
|
||||
<span>حداکثر قیمت</span>
|
||||
<PriceInput
|
||||
placeholder="تا"
|
||||
value={draftFilters.maxPrice}
|
||||
onChange={(value) => updateDraft('maxPrice', value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className={listStyles.field}>
|
||||
<span>دستهبندی</span>
|
||||
<select
|
||||
value={draftFilters.category}
|
||||
onChange={(e) => updateDraft('category', e.target.value)}
|
||||
>
|
||||
<option value="">همه دستهها</option>
|
||||
{productCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={listStyles.actions}>
|
||||
<button type="submit" className={listStyles.applyBtn}>
|
||||
اعمال فیلتر
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{filteredProducts.length === 0 ? (
|
||||
<p className={listStyles.empty}>محصولی برای نمایش وجود ندارد.</p>
|
||||
) : (
|
||||
<section className={listStyles.grid} aria-label="فهرست محصولات">
|
||||
{filteredProducts.map((product, index) => (
|
||||
<ProductCard
|
||||
key={`${location.key}-${product.id}-${index}`}
|
||||
product={product}
|
||||
index={index}
|
||||
visible={visible}
|
||||
onEdit={handleEdit}
|
||||
onOpenOptions={setOptionsProduct}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/products/new"
|
||||
className={listStyles.fab}
|
||||
aria-label="ثبت محصول جدید"
|
||||
data-tooltip="ثبت محصول جدید"
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
</Link>
|
||||
</main>
|
||||
|
||||
<ProductOptionsModal
|
||||
open={Boolean(optionsProduct)}
|
||||
product={
|
||||
optionsProduct
|
||||
? (products.find((item) => item.id === optionsProduct.id) ??
|
||||
optionsProduct)
|
||||
: null
|
||||
}
|
||||
onClose={() => setOptionsProduct(null)}
|
||||
onSave={handleSaveOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { AnimatedTiles } from '../components/AnimatedTiles'
|
||||
import { productTiles } from '../lib/nav'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
export function ProductsPage() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Products
|
||||
</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>
|
||||
|
||||
<AnimatedTiles tiles={productTiles} label="منوی محصولات" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
.settingsPage {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
margin-bottom: 8px;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.panel {
|
||||
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;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||
background: rgba(143, 65, 12, 0.05);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.exceptionList {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.exceptionCard,
|
||||
.addCardItem {
|
||||
grid-column: span 4;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exceptionCard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||
background: rgba(255, 250, 250, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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 input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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 select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: left 12px center;
|
||||
padding-inline-end: 12px;
|
||||
padding-inline-start: 36px;
|
||||
}
|
||||
|
||||
.branchList {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.branchCard,
|
||||
.branchAddItem {
|
||||
grid-column: span 6;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branchCard {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
padding-top: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||
background: rgba(255, 250, 250, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.branchAddress {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.branchAddItem {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.branchAddItem .addCard {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.08);
|
||||
}
|
||||
|
||||
.iconBtn[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(42, 24, 16, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.iconBtn[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
color: #b42318;
|
||||
background: rgba(180, 35, 24, 0.08);
|
||||
}
|
||||
|
||||
.addCardItem {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.addCard {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 148px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||
background: transparent;
|
||||
color: var(--brown);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.addCard:hover {
|
||||
border-color: rgba(143, 65, 12, 0.4);
|
||||
background: rgba(143, 65, 12, 0.05);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.savedMessage {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brown);
|
||||
color: #fff;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.saveBtn:hover {
|
||||
background: color-mix(in srgb, var(--brown) 88%, #000);
|
||||
}
|
||||
|
||||
.saveBtn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.exceptionCard,
|
||||
.addCardItem,
|
||||
.branchCard,
|
||||
.branchAddItem {
|
||||
grid-column: span 6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.branchGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.branchAddress {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.exceptionCard,
|
||||
.addCardItem,
|
||||
.branchCard,
|
||||
.branchAddItem {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||
import { districts } from '../data/districts'
|
||||
import { Header } from '../components/Header'
|
||||
import { PriceInput } from '../components/PriceInput'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './SettingsPage.module.css'
|
||||
|
||||
type ShippingException = {
|
||||
id: string
|
||||
district: string
|
||||
price: string
|
||||
}
|
||||
|
||||
type Branch = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
}
|
||||
|
||||
function createId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
function createException(): ShippingException {
|
||||
return {
|
||||
id: createId(),
|
||||
district: '',
|
||||
price: '',
|
||||
}
|
||||
}
|
||||
|
||||
function createBranch(): Branch {
|
||||
return {
|
||||
id: createId(),
|
||||
name: '',
|
||||
district: '',
|
||||
address: '',
|
||||
landline: '',
|
||||
cellNumber: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const location = useLocation()
|
||||
const formId = useId()
|
||||
const [exceptions, setExceptions] = useState<ShippingException[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [savedMessage, setSavedMessage] = useState('')
|
||||
|
||||
function touch() {
|
||||
setSavedMessage('')
|
||||
}
|
||||
|
||||
function updateException(
|
||||
id: string,
|
||||
patch: Partial<Pick<ShippingException, 'district' | 'price'>>,
|
||||
) {
|
||||
setExceptions((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
)
|
||||
touch()
|
||||
}
|
||||
|
||||
function updateBranch(id: string, patch: Partial<Omit<Branch, 'id'>>) {
|
||||
setBranches((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
)
|
||||
touch()
|
||||
}
|
||||
|
||||
function handleSave(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setSavedMessage('تنظیمات ذخیره شد (نسخه نمایشی)')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.settingsPage}`}>
|
||||
<Header />
|
||||
|
||||
<main className={styles.main}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Settings
|
||||
</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>
|
||||
|
||||
<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}>
|
||||
تمام ارسالها به سرتاسر قم رایگان میباشد مگر شما منطقهای را در
|
||||
زیر مستثنی کنید
|
||||
</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>
|
||||
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className={pageStyles.field}>
|
||||
<label htmlFor={`branch-district-${branch.id}`}>
|
||||
منطقه
|
||||
</label>
|
||||
<select
|
||||
id={`branch-district-${branch.id}`}
|
||||
value={branch.district}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, {
|
||||
district: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">انتخاب منطقه</option>
|
||||
{districts.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}
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</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}
|
||||
onChange={(e) =>
|
||||
updateBranch(branch.id, {
|
||||
cellNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={pageStyles.footer}>
|
||||
{savedMessage && (
|
||||
<p className={pageStyles.savedMessage} role="status">
|
||||
{savedMessage}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className={pageStyles.saveBtn}>
|
||||
ذخیره تنظیمات
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
.usersPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usersMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.topBlock {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.2fr 1fr auto;
|
||||
align-items: end;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
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);
|
||||
animation: fadeUp 0.5s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field span {
|
||||
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.85);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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 select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: left 12px center;
|
||||
padding-inline-end: 12px;
|
||||
padding-inline-start: 36px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.applyBtn:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.listWrap {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.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);
|
||||
animation: fadeUp 0.55s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1.4fr) minmax(120px, 1fr) 90px minmax(120px, 1.1fr) auto;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 250, 250, 0.55);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 250, 250, 0.9);
|
||||
}
|
||||
|
||||
.rowDisabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 500;
|
||||
color: var(--brown);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||
background: rgba(143, 65, 12, 0.05);
|
||||
color: var(--brown);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cell {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
direction: ltr;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(143, 65, 12, 0.18);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.switchOn {
|
||||
background: var(--brown);
|
||||
}
|
||||
|
||||
.switchThumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
inset-inline-start: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fffafa;
|
||||
box-shadow: 0 1px 4px rgba(42, 24, 16, 0.2);
|
||||
transition: inset-inline-start 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.switchOn .switchThumb {
|
||||
inset-inline-start: 21px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
color: var(--brown);
|
||||
background: rgba(143, 65, 12, 0.07);
|
||||
}
|
||||
|
||||
.roleElevated {
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.roleElevated:hover {
|
||||
color: var(--brown-dark);
|
||||
background: rgba(143, 65, 12, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.iconBtn:hover::after,
|
||||
.iconBtn:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
color: #9b2c2c;
|
||||
background: rgba(155, 44, 44, 0.08);
|
||||
}
|
||||
|
||||
.removeBtn:hover::after {
|
||||
background: #9b2c2c;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
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;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
color: var(--brown);
|
||||
border-color: rgba(143, 65, 12, 0.28);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pageBtnActive {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown);
|
||||
border-color: var(--brown);
|
||||
}
|
||||
|
||||
.pageBtnActive:hover:not(:disabled) {
|
||||
color: var(--text-on-dark);
|
||||
background: var(--brown-dark);
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-inline: 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
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);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--brown-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 32px rgba(143, 65, 12, 0.36);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.fab:hover::after,
|
||||
.fab:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cell {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.switchThumb,
|
||||
.iconBtn::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ChevronRight,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Header } from '../components/Header'
|
||||
import { ChangePasswordModal } from '../components/ChangePasswordModal'
|
||||
import { RoleModal } from '../components/RoleModal'
|
||||
import { UserModal, type UserFormValues } from '../components/UserModal'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { clearSession } from '../lib/auth'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
updateUser,
|
||||
updateUserPassword,
|
||||
updateUserRole,
|
||||
} from '../lib/usersApi'
|
||||
import {
|
||||
formatCellNumber,
|
||||
isElevatedRole,
|
||||
userRoleLabel,
|
||||
userRoles,
|
||||
type User,
|
||||
type UserRole,
|
||||
} from '../data/users'
|
||||
import { formatPrice, toEnglishDigits } from '../utils/price'
|
||||
import styles from './HomePage.module.css'
|
||||
import pageStyles from './UsersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
type Filters = {
|
||||
name: string
|
||||
cellNumber: string
|
||||
role: string
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = {
|
||||
name: '',
|
||||
cellNumber: '',
|
||||
role: '',
|
||||
}
|
||||
|
||||
function buildSearchQuery(filters: Filters) {
|
||||
const name = filters.name.trim()
|
||||
const cell = toEnglishDigits(filters.cellNumber).replace(/\D/g, '')
|
||||
if (cell) return cell
|
||||
if (name) return name
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [roleUser, setRoleUser] = useState<User | null>(null)
|
||||
const [passwordUser, setPasswordUser] = useState<User | null>(null)
|
||||
const [userModalOpen, setUserModalOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await listUsers({
|
||||
q: buildSearchQuery(appliedFilters),
|
||||
role: (appliedFilters.role as UserRole) || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setUsers(result.items)
|
||||
setTotal(result.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setUsers([])
|
||||
setTotal(0)
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: 'بارگذاری کاربران ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedFilters, page, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages)
|
||||
}, [page, totalPages])
|
||||
|
||||
function updateDraft<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||
setDraftFilters((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function handleApplyFilters(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setPage(1)
|
||||
setAppliedFilters(draftFilters)
|
||||
}
|
||||
|
||||
async function handleToggleDisabled(user: User) {
|
||||
setBusyId(user.id)
|
||||
setError('')
|
||||
try {
|
||||
const updated = await updateUser(user.id, { disabled: !user.disabled })
|
||||
setUsers((current) =>
|
||||
current.map((item) => (item.id === user.id ? updated : item)),
|
||||
)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت ناموفق بود.',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(user: User) {
|
||||
setEditingUser(user)
|
||||
setUserModalOpen(true)
|
||||
}
|
||||
|
||||
function handleOpenAddUser() {
|
||||
setEditingUser(null)
|
||||
setUserModalOpen(true)
|
||||
}
|
||||
|
||||
function handleCloseUserModal() {
|
||||
setUserModalOpen(false)
|
||||
setEditingUser(null)
|
||||
}
|
||||
|
||||
async function handleUserSubmit(values: UserFormValues) {
|
||||
setError('')
|
||||
if (editingUser) {
|
||||
const updated = await updateUser(editingUser.id, {
|
||||
title: values.title,
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
category: values.category,
|
||||
cellNumber: values.cellNumber,
|
||||
})
|
||||
setUsers((current) =>
|
||||
current.map((item) =>
|
||||
item.id === editingUser.id ? updated : item,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
if (!values.password) {
|
||||
throw new Error('رمز عبور الزامی است')
|
||||
}
|
||||
await createUser({
|
||||
title: values.title,
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
category: values.category,
|
||||
cellNumber: values.cellNumber,
|
||||
password: values.password,
|
||||
})
|
||||
if (page === 1) {
|
||||
await loadUsers()
|
||||
} else {
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
handleCloseUserModal()
|
||||
}
|
||||
|
||||
function handleChangePassword(user: User) {
|
||||
setPasswordUser(user)
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(password: string) {
|
||||
if (!passwordUser) return
|
||||
setError('')
|
||||
try {
|
||||
await updateUserPassword(passwordUser.id, password)
|
||||
setPasswordUser(null)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'تغییر رمز عبور ناموفق بود.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(user: User) {
|
||||
const confirmed = window.confirm(
|
||||
`کاربر «${user.firstName} ${user.lastName}» حذف شود؟`,
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setBusyId(user.id)
|
||||
setError('')
|
||||
try {
|
||||
await deleteUser(user.id)
|
||||
if (users.length === 1 && page > 1) {
|
||||
setPage((current) => current - 1)
|
||||
} else {
|
||||
await loadUsers()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'حذف کاربر ناموفق بود.')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleSubmit(role: UserRole) {
|
||||
if (!roleUser) return
|
||||
setError('')
|
||||
try {
|
||||
const updated = await updateUserRole(roleUser.id, role)
|
||||
setUsers((current) =>
|
||||
current.map((item) => (item.id === roleUser.id ? updated : item)),
|
||||
)
|
||||
setRoleUser(null)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'تغییر نقش ناموفق بود.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${pageStyles.usersPage}`}>
|
||||
<Header />
|
||||
|
||||
<main className={`${styles.main} ${pageStyles.usersMain}`}>
|
||||
<section
|
||||
key={`welcome-${location.key}`}
|
||||
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||
>
|
||||
<span className={styles.enBackdrop} aria-hidden>
|
||||
Users
|
||||
</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>
|
||||
|
||||
<form
|
||||
className={pageStyles.filters}
|
||||
onSubmit={handleApplyFilters}
|
||||
aria-label="فیلتر کاربران"
|
||||
>
|
||||
<label className={pageStyles.field}>
|
||||
<span>نام</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="نام یا نام خانوادگی"
|
||||
value={draftFilters.name}
|
||||
onChange={(e) => updateDraft('name', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={pageStyles.field}>
|
||||
<span>شماره موبایل</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="tel"
|
||||
placeholder="۰۹۱۲..."
|
||||
value={draftFilters.cellNumber}
|
||||
onChange={(e) => updateDraft('cellNumber', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={pageStyles.field}>
|
||||
<span>نقش</span>
|
||||
<select
|
||||
value={draftFilters.role}
|
||||
onChange={(e) => updateDraft('role', e.target.value)}
|
||||
>
|
||||
<option value="">همه نقشها</option>
|
||||
{userRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={pageStyles.actions}>
|
||||
<button type="submit" className={pageStyles.applyBtn}>
|
||||
اعمال فیلتر
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
) : users.length === 0 ? (
|
||||
<div className={pageStyles.panel}>
|
||||
<p className={pageStyles.empty}>کاربری برای نمایش وجود ندارد.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className={pageStyles.panel} aria-label="فهرست کاربران">
|
||||
<ul className={pageStyles.list}>
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className={`${pageStyles.row} ${user.disabled ? pageStyles.rowDisabled : ''}`}
|
||||
>
|
||||
<div className={pageStyles.identity}>
|
||||
<span className={pageStyles.name}>
|
||||
{user.title} {user.firstName} {user.lastName}
|
||||
</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}>
|
||||
{formatPrice(user.totalOrders)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.stat}>
|
||||
<span className={pageStyles.statLabel}>جمع تراکنشها</span>
|
||||
<span className={pageStyles.statValue}>
|
||||
{formatPrice(user.totalTransaction)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={pageStyles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.switch} ${!user.disabled ? pageStyles.switchOn : ''}`}
|
||||
role="switch"
|
||||
aria-checked={!user.disabled}
|
||||
aria-label={
|
||||
user.disabled ? 'فعالسازی کاربر' : 'غیرفعالسازی کاربر'
|
||||
}
|
||||
title={user.disabled ? 'غیرفعال' : 'فعال'}
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => void handleToggleDisabled(user)}
|
||||
>
|
||||
<span className={pageStyles.switchThumb} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => handleEdit(user)}
|
||||
>
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.iconBtn}
|
||||
aria-label="تغییر رمز عبور"
|
||||
data-tooltip="تغییر رمز"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => handleChangePassword(user)}
|
||||
>
|
||||
<KeyRound size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${isElevatedRole(user.role) ? pageStyles.roleElevated : ''}`}
|
||||
aria-label={`نقش: ${userRoleLabel[user.role]}`}
|
||||
data-tooltip="نقش"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => setRoleUser(user)}
|
||||
>
|
||||
<Shield
|
||||
size={17}
|
||||
strokeWidth={1.75}
|
||||
fill={isElevatedRole(user.role) ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={busyId === user.id}
|
||||
onClick={() => void handleRemove(user)}
|
||||
>
|
||||
<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}
|
||||
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>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={pageStyles.fab}
|
||||
aria-label="افزودن کاربر"
|
||||
data-tooltip="افزودن کاربر"
|
||||
onClick={handleOpenAddUser}
|
||||
>
|
||||
<Plus size={22} strokeWidth={1.75} />
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<RoleModal
|
||||
open={Boolean(roleUser)}
|
||||
userName={
|
||||
roleUser ? `${roleUser.firstName} ${roleUser.lastName}` : ''
|
||||
}
|
||||
currentRole={roleUser?.role ?? 'customer'}
|
||||
onClose={() => setRoleUser(null)}
|
||||
onSubmit={(role) => void handleRoleSubmit(role)}
|
||||
/>
|
||||
|
||||
<ChangePasswordModal
|
||||
open={Boolean(passwordUser)}
|
||||
userName={
|
||||
passwordUser
|
||||
? `${passwordUser.title} ${passwordUser.firstName} ${passwordUser.lastName}`
|
||||
: ''
|
||||
}
|
||||
onClose={() => setPasswordUser(null)}
|
||||
onSubmit={(password) => void handlePasswordSubmit(password)}
|
||||
/>
|
||||
|
||||
<UserModal
|
||||
open={userModalOpen}
|
||||
editingUser={editingUser}
|
||||
onClose={handleCloseUserModal}
|
||||
onSubmit={handleUserSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user