mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@meshkee/dashboard-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@meshkee/dashboard-core": "file:../dashboard-core",
|
||||
"lucide-react": "^1.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "~6.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.duplicatorGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.duplicatorGrid .gridHeader,
|
||||
.duplicatorGrid .gridRow {
|
||||
grid-template-columns: 2fr 2fr 5fr 2fr 2fr 1fr;
|
||||
}
|
||||
|
||||
.gridHeader {
|
||||
display: grid;
|
||||
gap: 8px 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.gridHeader > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gridRow {
|
||||
display: grid;
|
||||
gap: 8px 10px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.textField,
|
||||
.selectField {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.selectField {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding-right: var(--select-padding-end);
|
||||
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='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--select-arrow-offset) center;
|
||||
background-size: var(--select-arrow-size);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectFieldFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.selectField:disabled,
|
||||
.textField:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.textField:focus,
|
||||
.selectField:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.addBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.removeBtn:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.removeBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.helperText {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gridHeader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.duplicatorGrid .gridRow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import styles from './AddressListEditor.module.css'
|
||||
|
||||
export type CityOption = {
|
||||
id: string
|
||||
parentId: string | null
|
||||
level: 'country' | 'province' | 'city'
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
landlineCode: string | null
|
||||
slug: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type AddressListItem = {
|
||||
id?: string
|
||||
provinceSlug: string
|
||||
province: string
|
||||
city: string
|
||||
address: string
|
||||
postalCode: string
|
||||
landline: string
|
||||
}
|
||||
|
||||
export function createEmptyAddressItem(): AddressListItem {
|
||||
return {
|
||||
provinceSlug: '',
|
||||
province: '',
|
||||
city: '',
|
||||
address: '',
|
||||
postalCode: '',
|
||||
landline: '',
|
||||
}
|
||||
}
|
||||
|
||||
export type AddressLocale = 'en' | 'fa'
|
||||
|
||||
export function getLocationOptionLabel(option: CityOption, locale: AddressLocale = 'en') {
|
||||
return locale === 'fa' ? option.nameFa : option.nameEn
|
||||
}
|
||||
|
||||
export function matchProvinceByName(provinceName: string, provinces: CityOption[]) {
|
||||
const normalized = provinceName.trim().toLowerCase()
|
||||
return provinces.find(
|
||||
(item) =>
|
||||
item.nameFa === provinceName ||
|
||||
item.nameEn.toLowerCase() === normalized ||
|
||||
item.slug === normalized,
|
||||
)
|
||||
}
|
||||
|
||||
export function matchCityByName(cityName: string, cities: CityOption[]) {
|
||||
const normalized = cityName.trim().toLowerCase()
|
||||
return cities.find(
|
||||
(item) =>
|
||||
item.nameFa === cityName ||
|
||||
item.nameEn.toLowerCase() === normalized ||
|
||||
item.slug === normalized,
|
||||
)
|
||||
}
|
||||
|
||||
type AddressListEditorProps = {
|
||||
addresses: AddressListItem[]
|
||||
provinces: CityOption[]
|
||||
citiesByProvince: Record<string, CityOption[]>
|
||||
onAddressChange: (index: number, patch: Partial<AddressListItem>) => void
|
||||
onProvinceChange: (index: number, provinceSlug: string) => void | Promise<void>
|
||||
onAdd: () => void
|
||||
onRemove: (index: number) => void
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
locale?: AddressLocale
|
||||
title?: string
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export function AddressListEditor({
|
||||
addresses,
|
||||
provinces,
|
||||
citiesByProvince,
|
||||
onAddressChange,
|
||||
onProvinceChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
locale = 'en',
|
||||
title = 'Saved addresses',
|
||||
addLabel = 'Add address',
|
||||
}: AddressListEditorProps) {
|
||||
const selectClassName =
|
||||
locale === 'fa' ? `${styles.selectField} ${styles.selectFieldFa}` : styles.selectField
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3 className={styles.sectionTitle}>{title}</h3>
|
||||
<button type="button" className={styles.addBtn} onClick={onAdd} disabled={disabled}>
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className={styles.helperText}>Loading addresses...</p>
|
||||
) : (
|
||||
<div className={styles.duplicatorGrid}>
|
||||
<div className={styles.gridHeader}>
|
||||
<span>Province</span>
|
||||
<span>City</span>
|
||||
<span>Address</span>
|
||||
<span>Postal code</span>
|
||||
<span>Landline</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{addresses.map((item, index) => {
|
||||
const cities = item.provinceSlug
|
||||
? (citiesByProvince[item.provinceSlug] ?? [])
|
||||
: []
|
||||
|
||||
return (
|
||||
<div key={item.id ?? `address-${index}`} className={styles.gridRow}>
|
||||
<select
|
||||
className={selectClassName}
|
||||
value={item.provinceSlug}
|
||||
disabled={disabled}
|
||||
onChange={(e) => void onProvinceChange(index, e.target.value)}
|
||||
>
|
||||
<option value="">Select province</option>
|
||||
{provinces.map((province) => (
|
||||
<option key={province.id} value={province.slug}>
|
||||
{getLocationOptionLabel(province, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className={selectClassName}
|
||||
value={item.city}
|
||||
disabled={disabled || !item.provinceSlug}
|
||||
onChange={(e) => onAddressChange(index, { city: e.target.value })}
|
||||
>
|
||||
<option value="">Select city</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city.id} value={getLocationOptionLabel(city, locale)}>
|
||||
{getLocationOptionLabel(city, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
className={styles.textField}
|
||||
value={item.address}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAddressChange(index, { address: e.target.value })}
|
||||
placeholder="Street address"
|
||||
/>
|
||||
|
||||
<input
|
||||
className={styles.textField}
|
||||
value={item.postalCode}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAddressChange(index, { postalCode: e.target.value })}
|
||||
placeholder="Postal code"
|
||||
/>
|
||||
|
||||
<input
|
||||
className={styles.textField}
|
||||
value={item.landline}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAddressChange(index, { landline: e.target.value })}
|
||||
placeholder="Landline"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => onRemove(index)}
|
||||
aria-label="Remove address"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.breadcrumbs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.current {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import styles from './Breadcrumbs.module.css'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[]
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav className={styles.breadcrumbs} aria-label="Breadcrumb">
|
||||
<ol className={styles.list}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className={styles.item}>
|
||||
{index > 0 && (
|
||||
<ChevronRight size={14} className={styles.separator} aria-hidden="true" />
|
||||
)}
|
||||
{item.href && !isLast ? (
|
||||
<Link to={item.href} className={styles.link}>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? styles.current : styles.text}>{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
background: var(--bg-gradient-start);
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 32rem;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: background 0.2s, color 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.navBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.pageBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pageBtn.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import styles from './Pagination.module.css'
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
ariaLabel = 'Pagination',
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
|
||||
return (
|
||||
<nav className={styles.pagination} aria-label={ariaLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<div className={styles.pages}>
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${page === currentPage ? styles.active : ''}`}
|
||||
onClick={() => onPageChange(page)}
|
||||
aria-label={`Page ${page}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 28px 28px 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.strength {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.strengthHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.strengthTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.strengthLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tone_empty { color: var(--text-muted); }
|
||||
.tone_weak { color: #dc2626; }
|
||||
.tone_fair { color: #d97706; }
|
||||
.tone_good { color: #2563eb; }
|
||||
.tone_strong { color: #16a34a; }
|
||||
|
||||
.strengthTrack {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.strengthFill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
transition: width 0.22s ease, background-color 0.22s ease;
|
||||
}
|
||||
|
||||
.fill_empty { background: transparent; }
|
||||
.fill_weak { background: #ef4444; }
|
||||
.fill_fair { background: #f59e0b; }
|
||||
.fill_good { background: #3b82f6; }
|
||||
.fill_strong { background: #22c55e; }
|
||||
|
||||
.rules {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.ruleMet {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.ruleDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ruleMet .ruleDot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.error,
|
||||
.success {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cancelBtn:hover:not(:disabled) {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.submitBtn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.38);
|
||||
}
|
||||
|
||||
.submitBtn:disabled,
|
||||
.cancelBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
|
||||
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
|
||||
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||
|
||||
@keyframes overlayFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes modalFadeOut {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import styles from './PasswordResetModal.module.css'
|
||||
|
||||
export type ChangePasswordHandler = (
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) => Promise<{ message: string }>
|
||||
|
||||
export interface PasswordResetModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onChangePassword: ChangePasswordHandler
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
const PASSWORD_RULES = [
|
||||
{
|
||||
id: 'length',
|
||||
label: 'At least 8 characters',
|
||||
test: (value: string) => value.length >= 8,
|
||||
},
|
||||
{
|
||||
id: 'upper',
|
||||
label: 'One uppercase letter',
|
||||
test: (value: string) => /[A-Z]/.test(value),
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
label: 'One number',
|
||||
test: (value: string) => /\d/.test(value),
|
||||
},
|
||||
{
|
||||
id: 'special',
|
||||
label: 'One special character',
|
||||
test: (value: string) => /[^A-Za-z0-9]/.test(value),
|
||||
},
|
||||
] as const
|
||||
|
||||
type StrengthTone = 'empty' | 'weak' | 'fair' | 'good' | 'strong'
|
||||
|
||||
function getPasswordChecks(password: string) {
|
||||
return PASSWORD_RULES.map((rule) => ({
|
||||
id: rule.id,
|
||||
label: rule.label,
|
||||
met: rule.test(password),
|
||||
}))
|
||||
}
|
||||
|
||||
function getStrengthMeta(metCount: number, hasInput: boolean): {
|
||||
tone: StrengthTone
|
||||
label: string
|
||||
percent: number
|
||||
} {
|
||||
if (!hasInput || metCount === 0) {
|
||||
return { tone: 'empty', label: 'Enter a password', percent: 0 }
|
||||
}
|
||||
|
||||
if (metCount === 1) return { tone: 'weak', label: 'Weak', percent: 25 }
|
||||
if (metCount === 2) return { tone: 'fair', label: 'Fair', percent: 50 }
|
||||
if (metCount === 3) return { tone: 'good', label: 'Good', percent: 75 }
|
||||
return { tone: 'strong', label: 'Strong', percent: 100 }
|
||||
}
|
||||
|
||||
function getPasswordValidationError(password: string): string | null {
|
||||
const unmet = PASSWORD_RULES.filter((rule) => !rule.test(password))
|
||||
if (unmet.length === 0) return null
|
||||
return `Password must include: ${unmet.map((rule) => rule.label.toLowerCase()).join(', ')}.`
|
||||
}
|
||||
|
||||
export function PasswordResetModal({
|
||||
open,
|
||||
onClose,
|
||||
onChangePassword,
|
||||
title = 'Change password',
|
||||
subtitle = 'Enter your current password and choose a new one.',
|
||||
}: PasswordResetModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const checks = getPasswordChecks(newPassword)
|
||||
const metCount = checks.filter((check) => check.met).length
|
||||
const strength = getStrengthMeta(metCount, newPassword.length > 0)
|
||||
const passwordValid = metCount === PASSWORD_RULES.length
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose])
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
const validationError = getPasswordValidationError(newPassword)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New password and confirmation do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await onChangePassword(currentPassword, newPassword)
|
||||
setSuccess(result.message)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to change password.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="password-reset-title"
|
||||
>
|
||||
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h3 id="password-reset-title" className={styles.title}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={styles.subtitle}>{subtitle}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)} autoComplete="off">
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className={styles.success} role="status">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password-reset-current">Current password</label>
|
||||
<input
|
||||
id="password-reset-current"
|
||||
type="password"
|
||||
name="current-password-field"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password-reset-new">New password</label>
|
||||
<input
|
||||
id="password-reset-new"
|
||||
type="password"
|
||||
name="new-password-field"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="password-reset-strength password-reset-rules"
|
||||
/>
|
||||
|
||||
<div className={styles.strength} id="password-reset-strength">
|
||||
<div className={styles.strengthHeader}>
|
||||
<span className={styles.strengthTitle}>Password strength</span>
|
||||
<span className={`${styles.strengthLabel} ${styles[`tone_${strength.tone}`]}`}>
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.strengthTrack}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={strength.percent}
|
||||
aria-label="Password strength"
|
||||
>
|
||||
<div
|
||||
className={`${styles.strengthFill} ${styles[`fill_${strength.tone}`]}`}
|
||||
style={{ width: `${strength.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<ul className={styles.rules} id="password-reset-rules">
|
||||
{checks.map((check) => (
|
||||
<li
|
||||
key={check.id}
|
||||
className={`${styles.rule} ${check.met ? styles.ruleMet : ''}`}
|
||||
>
|
||||
<span className={styles.ruleDot} aria-hidden="true" />
|
||||
{check.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password-reset-confirm">Confirm password</label>
|
||||
<input
|
||||
id="password-reset-confirm"
|
||||
type="password"
|
||||
name="confirm-password-field"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !passwordValid}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.loaderWrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(var(--primary-dark-rgb) / 0.15);
|
||||
border-top-color: var(--primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from './RouteLoader.module.css'
|
||||
|
||||
export function RouteLoader() {
|
||||
return (
|
||||
<div className={styles.loaderWrap}>
|
||||
<div className={styles.loader} aria-label="Loading" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, rgba(219, 234, 254, 0.5) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
margin-bottom: 24px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.arrowBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover .arrowBtn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight, type LucideIcon } from 'lucide-react'
|
||||
import styles from './SectionCard.module.css'
|
||||
|
||||
interface SectionCardProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
linkText: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export function SectionCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
linkText,
|
||||
href,
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<Link to={href} className={styles.card} data-card-hover>
|
||||
<div className={styles.iconWrap}>
|
||||
<Icon size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<p className={styles.description}>{description}</p>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.link}>{linkText}</span>
|
||||
<span className={styles.arrowBtn} aria-hidden="true">
|
||||
<ArrowRight size={18} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './DomainGuard.module.css'
|
||||
|
||||
export interface DomainGuardConfig {
|
||||
isAllowedHost: (hostname?: string) => boolean
|
||||
getExpectedHost: () => string
|
||||
dashboardLabel: string
|
||||
}
|
||||
|
||||
export function createDomainGuard(config: DomainGuardConfig) {
|
||||
const { isAllowedHost, getExpectedHost, dashboardLabel } = config
|
||||
|
||||
return function DomainGuard({ children }: { children: ReactNode }) {
|
||||
if (isAllowedHost()) {
|
||||
return children
|
||||
}
|
||||
|
||||
const expectedHost = getExpectedHost()
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>Wrong domain</h1>
|
||||
<p className={styles.text}>
|
||||
This {dashboardLabel} is only available at <strong>{expectedHost}</strong>.
|
||||
</p>
|
||||
<p className={styles.hint}>
|
||||
Add <code>127.0.0.1 {expectedHost}</code> to your hosts file, then open{' '}
|
||||
<code>
|
||||
http://{expectedHost}
|
||||
{typeof window !== 'undefined' && window.location.port
|
||||
? `:${window.location.port}`
|
||||
: ''}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.toastIn {
|
||||
animation: toastIn 0.22s ease forwards;
|
||||
}
|
||||
|
||||
.toastOut {
|
||||
animation: toastOut 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.success {
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import styles from './Toast.module.css'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, variant?: ToastVariant) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
const TOAST_DURATION_MS = 3200
|
||||
const ANIMATION_MS = 200
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState<ToastItem | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const idRef = useRef(0)
|
||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current)
|
||||
dismissTimerRef.current = null
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback(() => {
|
||||
setClosing(true)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setToast(null)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
}, [])
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, variant: ToastVariant = 'info') => {
|
||||
clearTimers()
|
||||
idRef.current += 1
|
||||
setClosing(false)
|
||||
setToast({ id: idRef.current, message, variant })
|
||||
|
||||
dismissTimerRef.current = setTimeout(() => {
|
||||
dismissToast()
|
||||
}, TOAST_DURATION_MS)
|
||||
},
|
||||
[clearTimers, dismissToast],
|
||||
)
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers])
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
formatDashboardDocumentTitle,
|
||||
resolveRoutePageLabels,
|
||||
type RouteTitleRule,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
interface UseDashboardDocumentTitleOptions {
|
||||
businessName: string
|
||||
dashboardName: string
|
||||
pathname: string
|
||||
routeRules: RouteTitleRule[]
|
||||
pageLabels?: string[]
|
||||
}
|
||||
|
||||
export function useDashboardDocumentTitle({
|
||||
businessName,
|
||||
dashboardName,
|
||||
pathname,
|
||||
routeRules,
|
||||
pageLabels,
|
||||
}: UseDashboardDocumentTitleOptions) {
|
||||
useEffect(() => {
|
||||
const resolvedLabels = pageLabels ?? resolveRoutePageLabels(pathname, routeRules)
|
||||
document.title = formatDashboardDocumentTitle({
|
||||
businessName,
|
||||
dashboardName,
|
||||
pageLabels: resolvedLabels,
|
||||
})
|
||||
}, [businessName, dashboardName, pathname, routeRules, pageLabels])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
AddressListEditor,
|
||||
createEmptyAddressItem,
|
||||
getLocationOptionLabel,
|
||||
matchCityByName,
|
||||
matchProvinceByName,
|
||||
type AddressListItem,
|
||||
type AddressLocale,
|
||||
type CityOption,
|
||||
} from './components/AddressListEditor'
|
||||
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs'
|
||||
export { Pagination } from './components/Pagination'
|
||||
export { SectionCard } from './components/SectionCard'
|
||||
export { RouteLoader } from './components/RouteLoader'
|
||||
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'
|
||||
export { ToastProvider, useToast, type ToastVariant } from './context/ToastContext'
|
||||
export {
|
||||
PasswordResetModal,
|
||||
type ChangePasswordHandler,
|
||||
type PasswordResetModalProps,
|
||||
} from './components/PasswordResetModal'
|
||||
export { useDashboardDocumentTitle } from './hooks/useDashboardDocumentTitle'
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user