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:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# Meshkee Dashboard Shared Packages
Shared code for the three Meshkee admin UIs.
## Monorepo layout
```
MeshkeeApp/
apps/
super-admin/ # @meshkee/super-admin — port 5174
business/ # @meshkee/business-dashboard — port 5173
customer/ # @meshkee/customer-dashboard — port 5175
packages/
dashboard-core/ # @meshkee/dashboard-core
dashboard-ui/ # @meshkee/dashboard-ui
```
## Packages
### `@meshkee/dashboard-core`
- `createApiClient()` — parameterized token keys per app
- Auth types (`AuthUser`, `LoginResponse`, …)
- Utils (`cellNumber`, `irtPrice`)
- Subdomain helpers (`getCustomerDashboardHost`, …)
- Design tokens CSS (`styles/tokens.css`)
### `@meshkee/dashboard-ui`
- `Breadcrumbs`, `SectionCard`, `Pagination`
- `ToastProvider` / `useToast`
- `RouteLoader`
- `createDomainGuard()` factory
## Using in an app
Apps in this monorepo declare workspace dependencies:
```json
{
"dependencies": {
"@meshkee/dashboard-core": "*",
"@meshkee/dashboard-ui": "*"
}
}
```
Run `npm install` from the **MeshkeeApp** root (not inside the app alone).
Import tokens:
```css
@import '@meshkee/dashboard-core/styles/tokens.css';
```
## Migration status
| App | Shared packages |
|-----|-----------------|
| Customer | ✅ wired |
| Super Admin | ⏳ pending |
| Business | ⏳ pending |
## Commands
From `MeshkeeApp/`:
```bash
npm install
npm run build:packages
npm run dev:customer
```
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@meshkee/dashboard-core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./styles/tokens.css": "./src/styles/tokens.css"
},
"scripts": {
"build": "tsc -p tsconfig.json --noEmit",
"lint": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "~6.0.2"
}
}
@@ -0,0 +1,143 @@
export interface ApiClientConfig {
baseUrl: string
accessTokenKey: string
refreshTokenKey: string
}
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export function isAbortError(err: unknown): boolean {
return err instanceof DOMException && err.name === 'AbortError'
}
function parseErrorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== 'object') {
return fallback
}
const message = (payload as { message?: string | string[] }).message
if (Array.isArray(message)) {
return message.join(', ')
}
if (typeof message === 'string') {
return message
}
return fallback
}
export interface ApiClient {
getAccessToken: () => string | null
getRefreshToken: () => string | null
setTokens: (accessToken: string, refreshToken: string) => void
clearTokens: () => void
apiRequest: <T>(path: string, options?: ApiRequestOptions) => Promise<T>
}
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown
auth?: boolean
}
export function createApiClient(config: ApiClientConfig): ApiClient {
const { baseUrl, accessTokenKey, refreshTokenKey } = config
function getAccessToken() {
return localStorage.getItem(accessTokenKey)
}
function getRefreshToken() {
return localStorage.getItem(refreshTokenKey)
}
function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(accessTokenKey, accessToken)
localStorage.setItem(refreshTokenKey, refreshToken)
}
function clearTokens() {
localStorage.removeItem(accessTokenKey)
localStorage.removeItem(refreshTokenKey)
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken()
if (!refreshToken) {
return false
}
const response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!response.ok) {
clearTokens()
return false
}
const data = await response.json()
setTokens(data.accessToken, data.refreshToken)
return true
}
async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const { body, auth = false, headers, ...rest } = options
const requestHeaders = new Headers(headers)
if (body !== undefined) {
requestHeaders.set('Content-Type', 'application/json')
}
if (auth) {
const accessToken = getAccessToken()
if (accessToken) {
requestHeaders.set('Authorization', `Bearer ${accessToken}`)
}
}
const response = await fetch(`${baseUrl}${path}`, {
...rest,
headers: requestHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (response.status === 401 && auth) {
const refreshed = await refreshAccessToken()
if (refreshed) {
return apiRequest<T>(path, options)
}
}
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new ApiError(
parseErrorMessage(payload, `Request failed with status ${response.status}`),
response.status,
)
}
return payload as T
}
return {
getAccessToken,
getRefreshToken,
setTokens,
clearTokens,
apiRequest,
}
}
@@ -0,0 +1,57 @@
export const CUSTOMER_SUBDOMAIN_PREFIX = 'customer.'
export const BUSINESS_SUBDOMAIN_PREFIX = 'business.'
const DASHBOARD_PREFIXES = [CUSTOMER_SUBDOMAIN_PREFIX, BUSINESS_SUBDOMAIN_PREFIX] as const
export function stripDashboardSubdomain(hostname: string): string {
const host = hostname.toLowerCase().trim()
for (const prefix of DASHBOARD_PREFIXES) {
if (host.startsWith(prefix)) {
return host.slice(prefix.length)
}
}
return host
}
function isDashboardSubdomainHost(hostname: string): boolean {
const host = hostname.toLowerCase().trim()
return DASHBOARD_PREFIXES.some((prefix) => host.startsWith(prefix))
}
export function isBusinessDashboardHost(hostname: string): boolean {
return hostname.toLowerCase().trim().startsWith(BUSINESS_SUBDOMAIN_PREFIX)
}
export function isCustomerDashboardHost(hostname: string): boolean {
return hostname.toLowerCase().trim().startsWith(CUSTOMER_SUBDOMAIN_PREFIX)
}
/** Resolve tenant base domain (e.g. sanihome.ir) from the current host. */
export function getBaseDomainFromHost(
hostname: string,
envBaseDomain?: string,
): string {
const host = hostname.toLowerCase().trim()
// On business.* / customer.* the hostname always wins — one build serves all tenants.
if (isDashboardSubdomainHost(host)) {
return stripDashboardSubdomain(host)
}
// localhost fallback for dev without /etc/hosts subdomains
if (envBaseDomain?.trim()) {
return envBaseDomain.trim()
}
return stripDashboardSubdomain(host)
}
export function getCustomerDashboardHost(baseDomain: string): string {
return `${CUSTOMER_SUBDOMAIN_PREFIX}${baseDomain}`
}
export function getBusinessDashboardHost(baseDomain: string): string {
return `${BUSINESS_SUBDOMAIN_PREFIX}${baseDomain}`
}
+43
View File
@@ -0,0 +1,43 @@
export type { ApiClient, ApiClientConfig } from './api/createApiClient'
export { ApiError, createApiClient, isAbortError } from './api/createApiClient'
export type {
AuthUser,
DashboardType,
LoginResponse,
MeResponse,
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
UserProfile,
} from './types/auth'
export { toE164CellNumber, formatCellForDisplay } from './utils/cellNumber'
export {
calcDiscountPercent,
formatIrtInput,
formatIrtPrice,
hasStoreItemDiscount,
parseIrtInput,
} from './utils/irtPrice'
export {
formatDashboardDocumentTitle,
normalizeRoutePath,
resolveRoutePageLabels,
type DashboardDocumentTitleParts,
type RouteTitleRule,
} from './utils/documentTitle'
export { applyDocumentFavicon } from './utils/favicon'
export {
BUSINESS_SUBDOMAIN_PREFIX,
CUSTOMER_SUBDOMAIN_PREFIX,
getBaseDomainFromHost,
getBusinessDashboardHost,
getCustomerDashboardHost,
isBusinessDashboardHost,
isCustomerDashboardHost,
stripDashboardSubdomain,
} from './domain/subdomain'
@@ -0,0 +1,159 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--primary: #3b82f6;
--primary-glow: #3b82f6;
--primary-light: #dbeafe;
--primary-dark: #2563eb;
--primary-rgb: 59 130 246;
--primary-dark-rgb: 37 99 235;
--bg-gradient-start: color-mix(in srgb, var(--primary-light) 72%, #ffffff);
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #ffffff);
--bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
--glass-bg: rgba(255, 255, 255, 0.55);
--glass-border: rgba(255, 255, 255, 0.7);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08);
--blur-glass: 28px;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: rgba(148, 163, 184, 0.35);
--surface: rgba(255, 255, 255, 0.7);
--sidebar-width: 260px;
--radius: 16px;
--radius-sm: 12px;
--select-arrow-size: 16px;
--select-arrow-offset: 12px;
--select-padding-end: 2.5rem;
--field-font-size: 13px;
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--card-hover-lift: -4px;
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
}
html {
min-height: 100%;
}
body,
#root {
min-height: 100%;
}
body {
font-family: var(--font-en);
color: var(--text-primary);
background-color: var(--bg-gradient-mid);
background-image:
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.28), transparent 72%),
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.18), transparent 72%),
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.12), transparent 72%),
linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-mid) 50%, var(--bg-gradient-end) 100%);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
-webkit-font-smoothing: antialiased;
}
/* Interactive glass cards: lift + shadow on hover (no border change) */
[data-card-hover] {
transition: var(--card-hover-transition);
}
[data-card-hover]:hover,
[data-card-hover][data-card-hover-active='true'] {
transform: translateY(var(--card-hover-lift));
box-shadow: var(--card-hover-shadow);
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
a {
text-decoration: none;
color: inherit;
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
font-family: inherit;
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
min-height: var(--field-height);
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
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;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
textarea {
font-family: var(--font-fa), var(--font-en);
font-size: var(--field-font-size);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
:where(input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])) {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
line-height: 1.4;
box-sizing: border-box;
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']):focus,
textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder {
font-family: var(--font-fa), var(--font-en);
}
[dir='rtl'],
:lang(fa),
.faText {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
text-align: right;
}
[dir='rtl']::placeholder,
:lang(fa)::placeholder,
.faText::placeholder {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
}
+65
View File
@@ -0,0 +1,65 @@
export type DashboardType = 'super_admin' | 'business' | 'customer'
export interface UserProfile {
about: string
city: string
address: string
landline: string
backupPhone: string
postalCode: string
instagram: string
telegramId: string
linkedin: string
}
export interface AuthUser {
id: string
cellNumber: string
email: string | null
firstName: string | null
lastName: string | null
cellVerifiedAt: string | null
roles: string[]
dashboard: DashboardType
primaryRole?: string
roleLabel?: string
isSuperAdmin?: boolean
profile: UserProfile
businesses: {
id: string
name: string
slug: string
isOwner: boolean
teamRole: string | null
permissions: string[]
}[]
customerBusinesses: { id: string; name: string; slug: string }[]
}
export interface LoginResponse {
message: string
user: AuthUser
accessToken: string
refreshToken: string
}
export interface MeResponse {
user: AuthUser
}
export interface RegisterResponse extends LoginResponse {
smsEnabled?: boolean
registeredBusiness?: { id: string; name: string; slug: string }
}
export interface OtpSendResponse {
enabled: boolean
message: string
expiresInSeconds?: number
}
export interface OtpVerifyResponse {
enabled: boolean
verified: boolean
message: string
}
@@ -0,0 +1,30 @@
/** Normalize Iranian/local input to E.164 (e.g. +989121111111). */
export function toE164CellNumber(input: string): string {
const digits = input.replace(/\D/g, '')
if (!digits) {
return ''
}
if (digits.startsWith('98')) {
return `+${digits}`
}
if (digits.startsWith('0')) {
return `+98${digits.slice(1)}`
}
if (digits.length === 10 && digits.startsWith('9')) {
return `+98${digits}`
}
return `+${digits}`
}
/** Display E.164 Iranian numbers as local format (e.g. 0912...). */
export function formatCellForDisplay(cellNumber: string): string {
if (cellNumber.startsWith('+98')) {
return `0${cellNumber.slice(3)}`
}
return cellNumber
}
@@ -0,0 +1,54 @@
export interface DashboardDocumentTitleParts {
businessName: string
dashboardName: string
pageLabels?: string[]
}
/** Pattern: `{businessName} - {dashboardName} · {page}` */
export function formatDashboardDocumentTitle({
businessName,
dashboardName,
pageLabels = [],
}: DashboardDocumentTitleParts): string {
const business = businessName.trim() || 'Store'
const dashboard = dashboardName.trim()
const pages = pageLabels.map((label) => label.trim()).filter(Boolean)
if (pages.length === 0) {
return `${business} - ${dashboard}`
}
return `${business} - ${dashboard} · ${pages.join(' · ')}`
}
export interface RouteTitleRule {
match: string | RegExp
labels: string[]
}
export function normalizeRoutePath(pathname: string): string {
const path = pathname.split('?')[0]?.split('#')[0] ?? '/'
if (path === '/') return '/'
return path.replace(/\/+$/, '') || '/'
}
export function resolveRoutePageLabels(pathname: string, rules: RouteTitleRule[]): string[] {
const path = normalizeRoutePath(pathname)
const sorted = [...rules].sort((a, b) => {
const lenA = typeof a.match === 'string' ? a.match.length : 0
const lenB = typeof b.match === 'string' ? b.match.length : 0
return lenB - lenA
})
for (const rule of sorted) {
if (typeof rule.match === 'string' && rule.match === path) {
return rule.labels
}
if (rule.match instanceof RegExp && rule.match.test(path)) {
return rule.labels
}
}
return []
}
@@ -0,0 +1,35 @@
const FAVICON_ATTR = 'data-business-favicon'
function removeIconLinks(root: ParentNode = document.head) {
root
.querySelectorAll(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
)
.forEach((node) => node.remove())
}
/** Sets browser tab favicon links for the current tenant. Pass null to clear. */
export function applyDocumentFavicon(url: string | null | undefined) {
if (typeof document === 'undefined') return
removeIconLinks()
const href = url?.trim()
if (!href) return
// Bust browser favicon cache when the logo/favicon media URL changes.
const cacheBusted =
href.includes('?') ? `${href}&v=${Date.now()}` : `${href}?v=${Date.now()}`
for (const rel of ['icon', 'apple-touch-icon'] as const) {
const link = document.createElement('link')
link.setAttribute(FAVICON_ATTR, 'true')
link.rel = rel
link.href = cacheBusted
if (rel === 'icon') {
link.type = 'image/png'
link.sizes = '48x48'
}
document.head.appendChild(link)
}
}
@@ -0,0 +1,36 @@
/** Format a numeric price for display in Iranian Toman (IRT). */
export function formatIrtPrice(value: number | null | undefined): string {
if (value === null || value === undefined) return '—'
return `${Math.round(value).toLocaleString('en-US')} IRT`
}
/** Format raw digits into comma-separated groups while typing. */
export function formatIrtInput(raw: string): string {
const digits = raw.replace(/\D/g, '')
if (!digits) return ''
return Number(digits).toLocaleString('en-US')
}
/** Parse a formatted IRT input string back to a number. */
export function parseIrtInput(formatted: string): number | null {
const digits = formatted.replace(/\D/g, '')
if (!digits) return null
return Number(digits)
}
export function hasStoreItemDiscount(
price: number | null,
discountedPrice: number | null,
): boolean {
return (
price !== null &&
discountedPrice !== null &&
discountedPrice < price &&
discountedPrice >= 0
)
}
export function calcDiscountPercent(price: number, discountedPrice: number): number {
if (price <= 0 || discountedPrice >= price) return 0
return Math.round((1 - discountedPrice / price) * 100)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}
+27
View File
@@ -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
}
+4
View File
@@ -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])
}
+22
View File
@@ -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'
+15
View File
@@ -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"]
}