Initial commit of the Mado Motor admin dashboard.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user