Initial commit of the Mado Motor admin dashboard.

This commit is contained in:
2026-09-03 23:41:56 +03:30
commit b4c7b49bc6
99 changed files with 6785 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
.dt {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
text-align: center;
min-height: 0;
padding: 20px 10px 16px;
border: 1px solid transparent;
border-radius: 4px;
color: var(--color-primary);
transition: border-color 0.2s ease;
cursor: pointer;
}
.dt:hover {
border-color: #d8d8d8;
}
.dt--disabled {
cursor: default;
opacity: 0.75;
}
.dt--disabled:hover {
border-color: transparent;
}
.dt__icon {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 22px;
}
.dt h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 300;
line-height: 1.5;
color: var(--color-primary);
}
.dt h5 {
margin: 0;
font-size: 12px;
font-weight: 300;
color: rgba(0, 0, 0, 0.45);
line-height: 1.6;
max-width: 150px;
}
+31
View File
@@ -0,0 +1,31 @@
import { Link } from 'react-router-dom'
import type { DashboardTile } from '../data/tiles'
import './DashboardTile.css'
type Props = {
tile: DashboardTile
}
export default function DashboardTileCard({ tile }: Props) {
const body = (
<>
<img src={tile.icon} alt="" className="dt__icon" width={64} height={64} />
<h4>{tile.title}</h4>
<h5>{tile.subtitle}</h5>
</>
)
if (tile.to) {
return (
<Link to={tile.to} className="dt">
{body}
</Link>
)
}
return (
<div className="dt dt--disabled" title="به‌زودی">
{body}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
.fab {
position: fixed;
bottom: 32px;
left: max(24px, calc(5vw + 12px));
width: 56px;
height: 56px;
border: 0;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: 0 6px 16px rgba(44, 134, 237, 0.35);
z-index: 100;
}
.fab:hover {
background: var(--color-primary-deep);
}
+15
View File
@@ -0,0 +1,15 @@
import Icon from './Icon'
import './FabButton.css'
type Props = {
label: string
onClick?: () => void
}
export default function FabButton({ label, onClick }: Props) {
return (
<button type="button" className="fab" aria-label={label} onClick={onClick}>
<Icon name="plus" size={24} />
</button>
)
}
+167
View File
@@ -0,0 +1,167 @@
export type IconName =
| 'cart'
| 'scooter'
| 'user'
| 'star'
| 'chat'
| 'wrench'
| 'doc'
| 'store'
| 'rss'
| 'home'
| 'power'
| 'edit'
| 'plus'
| 'chevron'
| 'trash'
| 'info'
| 'phone'
type IconProps = {
name: IconName
size?: number
className?: string
}
export default function Icon({ name, size = 24, className }: IconProps) {
const common = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.6,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
className,
'aria-hidden': true,
}
switch (name) {
case 'home':
return (
<svg {...common}>
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V21h14V9.5" />
</svg>
)
case 'power':
return (
<svg {...common}>
<path d="M12 2v10" />
<path d="M6.5 5.5a8 8 0 1 0 11 0" />
</svg>
)
case 'user':
return (
<svg {...common}>
<circle cx="12" cy="8" r="3.5" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
</svg>
)
case 'cart':
return (
<svg {...common}>
<path d="M3 4h2l1.5 11h11L20 7H7" />
<circle cx="9" cy="19" r="1.4" />
<circle cx="16" cy="19" r="1.4" />
</svg>
)
case 'scooter':
return (
<svg {...common}>
<circle cx="6.5" cy="17.5" r="2.5" />
<circle cx="18" cy="17.5" r="2.5" />
<path d="M9 17.5h6.5" />
<path d="M12 17.5V9l5-3" />
<path d="M8 12h4" />
</svg>
)
case 'star':
return (
<svg {...common}>
<path d="m12 3 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.9 7.2 18l.9-5.4L4.2 8.7l5.4-.8L12 3z" />
</svg>
)
case 'chat':
return (
<svg {...common}>
<path d="M4 5h16v10H8l-4 4V5z" />
<path d="M8 9h8M8 12h5" />
</svg>
)
case 'wrench':
return (
<svg {...common}>
<path d="M14.5 5.5a4 4 0 0 0-5.4 5.4L4 16v4h4l5.1-5.1a4 4 0 0 0 1.4-9.4z" />
</svg>
)
case 'doc':
return (
<svg {...common}>
<path d="M7 3h7l4 4v14H7z" />
<path d="M14 3v4h4" />
<path d="M9 12h6M9 16h6" />
</svg>
)
case 'store':
return (
<svg {...common}>
<path d="M4 9 6 4h12l2 5" />
<path d="M4 9v11h16V9" />
<path d="M10 20v-6h4v6" />
</svg>
)
case 'rss':
return (
<svg {...common}>
<path d="M5 18a1.5 1.5 0 1 0 0.01 0" />
<path d="M5 11a7 7 0 0 1 7 7" />
<path d="M5 6a12 12 0 0 1 12 12" />
</svg>
)
case 'edit':
return (
<svg {...common}>
<path d="M4 20h4l11-11-4-4L4 16v4z" />
<path d="m13 7 4 4" />
</svg>
)
case 'trash':
return (
<svg {...common}>
<path d="M4 7h16" />
<path d="M9 7V5h6v2" />
<path d="M6 7l1 14h10l1-14" />
<path d="M10 11v6M14 11v6" />
</svg>
)
case 'plus':
return (
<svg {...common}>
<path d="M12 5v14M5 12h14" />
</svg>
)
case 'chevron':
return (
<svg {...common}>
<path d="m9 5 7 7-7 7" />
</svg>
)
case 'info':
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 10v6M12 7h.01" />
</svg>
)
case 'phone':
return (
<svg {...common}>
<path d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 5 5l1.5-2 4 1.5v3a2 2 0 0 1-2 2A15 15 0 0 1 4.5 5.5a2 2 0 0 1 2-2z" />
</svg>
)
default:
return null
}
}
+65
View File
@@ -0,0 +1,65 @@
.page-title {
display: flex;
align-items: center;
margin-bottom: 28px;
}
.page-title__back {
margin-left: 0;
margin-right: 8px;
border: 0;
background: transparent;
padding: 4px;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
}
.page-title__back img {
display: block;
width: 15px;
height: auto;
}
.page-title__divider {
width: 1px;
align-self: stretch;
min-height: 36px;
margin: 8px 20px;
background: #e0e0e0;
flex-shrink: 0;
}
.page-title__content {
display: flex;
align-items: center;
min-width: 0;
}
.page-title__icon {
height: auto;
margin-left: 12px;
flex-shrink: 0;
}
.page-title__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.page-title h1 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.4;
}
.page-title p {
margin: 2px 0 0;
font-size: 0.9rem;
color: gray;
font-weight: 300;
}
+43
View File
@@ -0,0 +1,43 @@
import userIcon from '../assets/images/user-icon.svg'
import backChevron from '../assets/images/back-chevron.png'
import './PageTitle.css'
type Props = {
title: string
subtitle?: string
image?: string
imageWidth?: number
showBack?: boolean
onBack?: () => void
}
export default function PageTitle({
title,
subtitle,
image = userIcon,
imageWidth = 45,
showBack = true,
onBack,
}: Props) {
return (
<div className="page-title">
{showBack ? (
<button type="button" className="page-title__back" onClick={onBack} aria-label="بازگشت">
<img src={backChevron} alt="" width={15} height={22} />
</button>
) : null}
{showBack ? <span className="page-title__divider" aria-hidden /> : null}
<div className="page-title__content">
{image ? (
<img src={image} alt="" className="page-title__icon" style={{ width: imageWidth }} />
) : null}
<div className="page-title__text">
<h1>{title}</h1>
{subtitle ? <p>{subtitle}</p> : null}
</div>
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 20px;
flex-wrap: wrap;
}
.pager__page,
.pager__nav {
min-width: 34px;
height: 34px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: #e3f2fd;
color: #1565c0;
display: grid;
place-items: center;
cursor: pointer;
font-family: var(--font-fa);
font-size: 14px;
font-weight: 500;
}
.pager__page.is-active {
background: var(--color-primary);
color: #fff;
}
.pager__nav:hover:not(:disabled),
.pager__page:hover:not(.is-active) {
background: #bbdefb;
}
.pager__nav:disabled {
opacity: 0.35;
cursor: default;
}
.pager__nav--next {
transform: scaleX(-1);
}
.pager__ellipsis {
min-width: 20px;
text-align: center;
color: #9e9e9e;
}
+73
View File
@@ -0,0 +1,73 @@
import Icon from './Icon'
import { toFaDigits } from '../lib/faDigits'
import './Pagination.css'
type Props = {
page: number
pageCount: number
onPageChange: (page: number) => void
}
function visiblePages(page: number, pageCount: number): Array<number | 'ellipsis'> {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, i) => i + 1)
}
const set = new Set([1, pageCount, page, page - 1, page + 1, page - 2, page + 2])
const nums = [...set].filter((n) => n >= 1 && n <= pageCount).sort((a, b) => a - b)
const out: Array<number | 'ellipsis'> = []
for (const n of nums) {
if (out.length > 0) {
const prev = out[out.length - 1]
if (typeof prev === 'number' && n - prev > 1) out.push('ellipsis')
}
out.push(n)
}
return out
}
export default function Pagination({ page, pageCount, onPageChange }: Props) {
if (pageCount <= 1) return null
return (
<nav className="pager" aria-label="صفحه‌بندی">
<button
type="button"
className="pager__nav"
aria-label="صفحه قبل"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<Icon name="chevron" size={16} />
</button>
{visiblePages(page, pageCount).map((item, index) =>
item === 'ellipsis' ? (
<span key={`e-${index}`} className="pager__ellipsis">
…
</span>
) : (
<button
key={item}
type="button"
className={`pager__page${item === page ? ' is-active' : ''}`}
aria-current={item === page ? 'page' : undefined}
onClick={() => onPageChange(item)}
>
{toFaDigits(item)}
</button>
),
)}
<button
type="button"
className="pager__nav pager__nav--next"
aria-label="صفحه بعد"
disabled={page >= pageCount}
onClick={() => onPageChange(page + 1)}
>
<Icon name="chevron" size={16} />
</button>
</nav>
)
}
+40
View File
@@ -0,0 +1,40 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-white);
}
.btn--primary:hover:not(:disabled) {
background: var(--color-primary-deep);
}
.btn--secondary {
background: var(--color-secondary);
color: #444;
}
.btn--secondary:hover:not(:disabled) {
background: #e4e4e4;
}
.btn--ghost {
background: transparent;
color: #666;
}
+20
View File
@@ -0,0 +1,20 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import './PrimaryButton.css'
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'ghost'
children: ReactNode
}
export default function PrimaryButton({
variant = 'primary',
children,
className = '',
...rest
}: Props) {
return (
<button className={`btn btn--${variant} ${className}`.trim()} {...rest}>
{children}
</button>
)
}
+33
View File
@@ -0,0 +1,33 @@
.tf {
position: relative;
display: block;
width: 100%;
}
.tf__label {
position: absolute;
top: -9px;
right: 14px;
background: var(--color-white);
padding: 0 6px;
font-size: 12px;
color: #777;
z-index: 1;
}
.tf__input {
width: 100%;
height: 48px;
border: 1px solid #d7d7d7;
border-radius: 6px;
padding: 0 14px;
background: #f7fafc;
color: var(--color-text);
outline: none;
transition: border-color 0.15s ease, background 0.15s ease;
}
.tf__input:focus {
border-color: var(--color-primary);
background: var(--color-white);
}
+33
View File
@@ -0,0 +1,33 @@
import './TextField.css'
type TextFieldProps = {
label: string
value: string
onChange: (value: string) => void
type?: string
autoComplete?: string
dir?: 'rtl' | 'ltr'
}
export default function TextField({
label,
value,
onChange,
type = 'text',
autoComplete,
dir,
}: TextFieldProps) {
return (
<label className="tf">
<span className="tf__label">{label}</span>
<input
className="tf__input"
type={type}
value={value}
autoComplete={autoComplete}
dir={dir}
onChange={(e) => onChange(e.target.value)}
/>
</label>
)
}