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
+4
View File
@@ -0,0 +1,4 @@
VITE_API_BASE_URL=http://localhost:3000/api/v1
# Hostname for this dashboard (meshkee.app in production).
VITE_ADMIN_DOMAIN=meshkee.app
# Local HTTPS certs (gitignored): mkcert -cert-file .certs/meshkee.app.pem -key-file .certs/meshkee.app-key.pem meshkee.app
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.certs
.env
.DS_Store
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.fontcdn.ir/Font/Persian/IranYekan/IranYekan.css"
rel="stylesheet"
/>
<title>Meshkee Super Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1433
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@meshkee/super-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@meshkee/dashboard-ui": "*",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+43
View File
@@ -0,0 +1,43 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { ToastProvider } from './context/ToastContext'
import { AdminDomainGuard } from './components/AdminDomainGuard'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
import { HomePage } from './pages/HomePage'
import { BusinessesPage } from './pages/BusinessesPage'
import { UsersPage } from './pages/UsersPage'
import { WebsitesPage } from './pages/WebsitesPage'
import { ProfilePage } from './pages/ProfilePage'
import { LoginPage } from './pages/LoginPage'
function App() {
return (
<AdminDomainGuard>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
<Route element={<GuestRoute />}>
<Route path="login" element={<LoginPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<PageLayout />}>
<Route index element={<HomePage />} />
<Route path="businesses" element={<BusinessesPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="websites" element={<WebsitesPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route>
</Route>
</Routes>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</AdminDomainGuard>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -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,31 @@
import type { ReactNode } from 'react'
import { getAdminDomain, isAllowedAdminHost } from '../lib/config'
import styles from './AdminDomainGuard.module.css'
export function AdminDomainGuard({ children }: { children: ReactNode }) {
if (isAllowedAdminHost()) {
return children
}
const expectedDomain = getAdminDomain()
return (
<div className={styles.page}>
<div className={styles.card}>
<h1 className={styles.title}>Wrong domain</h1>
<p className={styles.text}>
This dashboard is only available at{' '}
<strong>{expectedDomain}</strong>.
</p>
<p className={styles.hint}>
Add <code>127.0.0.1 {expectedDomain}</code> to your hosts file, then open{' '}
<code>
https://{expectedDomain}
{window.location.port ? `:${window.location.port}` : ''}
</code>
. Your browser may show a certificate warning on first visit that is expected for local HTTPS.
</p>
</div>
</div>
)
}
@@ -0,0 +1,122 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(127, 29, 29, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal {
position: relative;
width: 100%;
max-width: 400px;
padding: 32px 28px 24px;
text-align: center;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(239, 68, 68, 0.15);
}
.iconWrap {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
border-radius: 50%;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.message {
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
margin-bottom: 24px;
}
.actions {
display: flex;
gap: 10px;
justify-content: center;
}
.cancelBtn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover {
background: rgba(148, 163, 184, 0.15);
}
.deleteBtn {
padding: 10px 22px;
font-size: 14px;
font-weight: 600;
color: white;
background: #ef4444;
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);
transition: background 0.2s, transform 0.2s;
}
.deleteBtn:hover {
background: #dc2626;
transform: translateY(-1px);
}
.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(239, 68, 68, 0.1);
color: #ef4444;
}
.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,86 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css'
interface ConfirmDeleteModalProps {
open: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
const ANIMATION_MS = 220
export function ConfirmDeleteModal({
open,
title,
message,
onConfirm,
onCancel,
}: ConfirmDeleteModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} 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') onCancel()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onCancel])
if (!mounted) return null
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div className={styles.iconWrap}>
<AlertTriangle size={28} />
</div>
<h3 id="confirm-delete-title" className={styles.title}>
{title}
</h3>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
Cancel
</button>
<button type="button" className={styles.deleteBtn} onClick={onConfirm}>
Delete
</button>
</div>
<button className={styles.closeBtn} onClick={onCancel} aria-label="Close">
<X size={18} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import styles from './RouteLoader.module.css'
export function GuestRoute() {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className={styles.loaderWrap}>
<div className={styles.loader} aria-label="Loading" />
</div>
)
}
if (user) {
return <Navigate to="/" replace />
}
return <Outlet />
}
@@ -0,0 +1,198 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 50;
}
.left {
display: flex;
align-items: center;
gap: 16px;
}
.menuBtn {
display: none;
padding: 8px;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s;
}
.menuBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.right {
display: flex;
align-items: center;
gap: 20px;
}
.iconBtn {
position: relative;
padding: 10px;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.iconBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.badge {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 100%);
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 50%;
border: 2px solid white;
}
.profileWrap {
position: relative;
}
.profile {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px 6px 6px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
cursor: pointer;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo {
display: flex;
flex-direction: column;
}
.name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.role {
font-size: 12px;
color: var(--text-muted);
}
.chevron {
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
padding: 6px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: 0 12px 32px rgba(var(--primary-rgb) / 0.14);
z-index: 60;
animation: dropdownIn 0.15s ease;
}
.dropdownItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
text-align: left;
}
.dropdownItem:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.dropdownItem:last-child:hover {
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.menuBtn {
display: flex;
}
.profileInfo {
display: none;
}
.chevron {
display: none;
}
}
@media (max-width: 480px) {
.header {
padding: 12px 16px;
}
.title {
font-size: 16px;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
import { PasswordResetModal } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { changePassword } from '../services/profileService'
import styles from './Header.module.css'
export function Header() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const displayName =
[user?.firstName, user?.lastName].filter(Boolean).join(' ') ||
user?.cellNumber ||
'Super Admin'
const avatarSeed = encodeURIComponent(user?.cellNumber ?? 'admin')
useEffect(() => {
if (!menuOpen) return
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [menuOpen])
function handleLogout() {
logout()
navigate('/login')
}
function openPasswordModal() {
setMenuOpen(false)
setPasswordModalOpen(true)
}
const profileMenuItems = [
{ icon: User, label: 'Profile', action: () => { setMenuOpen(false); navigate('/profile') } },
{ icon: KeyRound, label: 'Password reset', action: openPasswordModal },
{ icon: LogOut, label: 'Logout', action: handleLogout },
]
return (
<>
<header className={styles.header}>
<div className={styles.left}>
<button className={styles.menuBtn} aria-label="Toggle menu">
<Menu size={22} />
</button>
<h1 className={styles.title}>Super Admin Dashboard</h1>
</div>
<div className={styles.right}>
<button className={styles.iconBtn} aria-label="Messages">
<MessageSquare size={20} />
<span className={styles.badge}>5</span>
</button>
<button className={styles.iconBtn} aria-label="Notifications">
<Bell size={20} />
<span className={styles.badge}>3</span>
</button>
<div className={styles.profileWrap} ref={menuRef}>
<button
type="button"
className={styles.profile}
data-card-hover
data-card-hover-active={menuOpen ? 'true' : undefined}
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<img
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${avatarSeed}`}
alt={displayName}
className={styles.avatar}
/>
<div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span>
<span className={styles.role}>Super Administrator</span>
</div>
<ChevronDown
size={16}
className={`${styles.chevron} ${menuOpen ? styles.chevronOpen : ''}`}
/>
</button>
{menuOpen && (
<div className={styles.dropdown} role="menu">
{profileMenuItems.map(({ icon: Icon, label, action }) => (
<button
key={label}
type="button"
className={styles.dropdownItem}
role="menuitem"
onClick={action}
>
<Icon size={16} />
<span>{label}</span>
</button>
))}
</div>
)}
</div>
</div>
</header>
<PasswordResetModal
open={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
onChangePassword={changePassword}
/>
</>
)
}
@@ -0,0 +1,64 @@
.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: 300;
padding: 20px;
}
.modal {
width: 100%;
max-width: 520px;
background: rgba(255, 255, 255, 0.9);
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);
overflow: hidden;
}
.modalWide {
max-width: 640px;
}
.header {
position: relative;
padding: 18px 18px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.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);
}
.body {
padding: 18px;
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import styles from './Modal.module.css'
interface ModalProps {
open: boolean
title: string
children: React.ReactNode
onClose: () => void
wide?: boolean
}
export function Modal({ open, title, children, onClose, wide }: ModalProps) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={onClose}>
<div className={`${styles.modal} ${wide ? styles.modalWide : ''}`} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<h3 className={styles.title}>{title}</h3>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={18} />
</button>
</div>
<div className={styles.body}>{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,153 @@
.wrapper {
position: relative;
}
.trigger {
width: 100%;
min-height: var(--field-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
text-align: left;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
}
.trigger:hover:not(:disabled) {
border-color: rgba(var(--primary-rgb) / 0.35);
}
.triggerOpen,
.trigger:focus-visible {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
outline: none;
}
.trigger:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.triggerPlaceholder .triggerText {
color: var(--text-muted);
}
.triggerText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
max-height: 200px;
overflow-y: auto;
margin: 0;
padding: 6px;
list-style: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
}
.searchRow {
padding: 2px;
}
.searchInput {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.searchInput:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.searchInput::placeholder {
color: var(--text-muted);
opacity: 1;
}
.option {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
font-size: 13px;
color: var(--text-primary);
text-align: left;
border-radius: 8px;
transition: background 0.15s;
}
.option:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.optionSelected {
color: var(--primary);
font-weight: 500;
}
.check {
width: 16px;
height: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid rgba(148, 163, 184, 0.55);
border-radius: 4px;
color: white;
background: white;
}
.checkSelected {
border-color: var(--primary);
background: var(--primary);
}
.empty {
padding: 10px 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
@@ -0,0 +1,149 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import styles from './MultiSelectDropdown.module.css'
export interface MultiSelectOption<T extends string | number = number> {
value: T
label: string
depth?: number
}
interface MultiSelectDropdownProps<T extends string | number = number> {
options: MultiSelectOption<T>[]
value: T[]
onChange: (value: T[]) => void
placeholder?: string
searchPlaceholder?: string
disabled?: boolean
id?: string
}
export function MultiSelectDropdown<T extends string | number = number>({
options,
value,
onChange,
placeholder = 'Select options',
searchPlaceholder = 'Search...',
disabled = false,
id,
}: MultiSelectDropdownProps<T>) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const selectedLabels = useMemo(
() =>
options
.filter((option) => value.includes(option.value))
.map((option) => option.label),
[options, value],
)
const filteredOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) return options
return options.filter((o) => o.label.toLowerCase().includes(query))
}, [options, searchQuery])
const triggerLabel = useMemo(() => {
if (selectedLabels.length === 0) return placeholder
if (selectedLabels.length <= 2) return selectedLabels.join(', ')
return `${selectedLabels.slice(0, 2).join(', ')} +${selectedLabels.length - 2}`
}, [placeholder, selectedLabels])
useEffect(() => {
if (!open) return
setSearchQuery('')
const t = window.setTimeout(() => {
searchInputRef.current?.focus()
}, 0)
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
window.clearTimeout(t)
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [open])
function toggleOption(optionValue: T) {
if (value.includes(optionValue)) {
onChange(value.filter((item) => item !== optionValue))
return
}
onChange([...value, optionValue])
}
return (
<div className={styles.wrapper} ref={containerRef}>
<button
id={id}
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${
selectedLabels.length === 0 ? styles.triggerPlaceholder : ''
}`}
onClick={() => !disabled && setOpen((prev) => !prev)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className={styles.triggerText}>{triggerLabel}</span>
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
</button>
{open && (
<ul className={styles.dropdown} role="listbox" aria-multiselectable="true">
<li className={styles.searchRow}>
<input
ref={searchInputRef}
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
aria-label="Search options"
disabled={disabled}
/>
</li>
{filteredOptions.length === 0 ? (
<li className={styles.empty}>{options.length === 0 ? 'No options available.' : 'No matching options.'}</li>
) : (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<li key={String(option.value)}>
<button
type="button"
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
onClick={() => toggleOption(option.value)}
>
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
{selected && <Check size={12} strokeWidth={3} />}
</span>
<span>{option.label}</span>
</button>
</li>
)
})
)}
</ul>
)}
</div>
)
}
@@ -0,0 +1,81 @@
.content {
width: 100%;
padding: 24px;
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.pageTitle {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.pageSubtitle {
font-size: 14px;
color: var(--text-secondary);
}
.dateBadge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(var(--primary-rgb) / 0.22);
border-radius: 50px;
font-size: 13px;
font-weight: 500;
color: var(--primary);
white-space: nowrap;
box-shadow: var(--glass-shadow);
}
.dateBadge svg {
color: var(--primary-glow);
}
.gridHome {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 1100px) {
.gridHome {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.content {
padding: 20px 16px;
}
.pageHeader {
flex-direction: column;
gap: 16px;
}
.pageTitle {
font-size: 22px;
}
.dateBadge {
align-self: flex-start;
}
.gridHome {
grid-template-columns: 1fr;
gap: 16px;
}
}
@@ -0,0 +1,113 @@
.layout {
min-height: 100vh;
position: relative;
overflow: hidden;
}
.bgOrbs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.bgOrbs::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 50%, transparent 30%, rgba(255, 255, 255, 0.15) 100%);
backdrop-filter: blur(1px);
}
.orb1,
.orb2,
.orb3,
.orb4 {
position: absolute;
border-radius: 50%;
filter: blur(100px);
will-change: transform;
}
.orb1 {
width: 560px;
height: 560px;
background: radial-gradient(circle, #93c5fd 0%, #60a5fa 55%, transparent 72%);
opacity: 0.65;
top: -140px;
right: -120px;
animation: floatOrb1 18s ease-in-out infinite;
}
.orb2 {
width: 480px;
height: 480px;
background: radial-gradient(circle, #818cf8 0%, #6366f1 50%, transparent 70%);
opacity: 0.55;
bottom: -100px;
left: 15%;
animation: floatOrb2 22s ease-in-out infinite;
}
.orb3 {
width: 380px;
height: 380px;
background: radial-gradient(circle, #7dd3fc 0%, #38bdf8 55%, transparent 72%);
opacity: 0.5;
top: 38%;
left: -100px;
animation: floatOrb3 20s ease-in-out infinite;
}
.orb4 {
width: 320px;
height: 320px;
background: radial-gradient(circle, #c4b5fd 0%, #a78bfa 50%, transparent 70%);
opacity: 0.45;
top: 12%;
right: 28%;
animation: floatOrb4 24s ease-in-out infinite;
}
.main {
margin-left: var(--sidebar-width);
min-height: 100vh;
position: relative;
z-index: 1;
}
@keyframes floatOrb1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-30px, 25px) scale(1.06); }
}
@keyframes floatOrb2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(35px, -20px) scale(1.05); }
}
@keyframes floatOrb3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, 30px) scale(1.08); }
}
@keyframes floatOrb4 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-25px, -15px) scale(1.04); }
}
@media (max-width: 768px) {
.main {
margin-left: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.orb1,
.orb2,
.orb3,
.orb4 {
animation: none;
}
}
@@ -0,0 +1,24 @@
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import styles from './PageLayout.module.css'
export function PageLayout() {
return (
<div className={styles.layout}>
<div className={styles.bgOrbs} aria-hidden="true">
<div className={styles.orb1} />
<div className={styles.orb2} />
<div className={styles.orb3} />
<div className={styles.orb4} />
</div>
<Sidebar />
<div className={styles.main}>
<Header />
<Outlet />
</div>
</div>
)
}
@@ -0,0 +1,58 @@
.wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.swatch {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.55);
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.swatch:hover:not(:disabled) {
border-color: rgba(148, 163, 184, 0.45);
}
.swatchSelected {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb) / 0.18);
background: rgba(255, 255, 255, 0.82);
}
.swatch:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.swatchColor {
width: 22px;
height: 22px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12);
flex-shrink: 0;
}
.swatchLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
@media (min-width: 520px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -0,0 +1,45 @@
import {
BUSINESS_PRIMARY_COLOR_IDS,
BUSINESS_PRIMARY_COLOR_PALETTE,
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
import styles from './PrimaryColorPicker.module.css'
interface PrimaryColorPickerProps {
value: BusinessPrimaryColorId
onChange: (value: BusinessPrimaryColorId) => void
disabled?: boolean
}
export function PrimaryColorPicker({ value, onChange, disabled = false }: PrimaryColorPickerProps) {
return (
<div className={styles.wrap}>
<div className={styles.grid} role="radiogroup" aria-label="Primary color">
{BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
const tokens = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
const selected = value === colorId
return (
<button
key={colorId}
type="button"
role="radio"
aria-checked={selected}
aria-label={tokens.label}
className={`${styles.swatch} ${selected ? styles.swatchSelected : ''}`}
disabled={disabled}
onClick={() => onChange(colorId)}
>
<span
className={styles.swatchColor}
style={{ backgroundColor: tokens.primary }}
aria-hidden="true"
/>
<span className={styles.swatchLabel}>{tokens.label}</span>
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,84 @@
.wrap {
position: relative;
display: inline-flex;
}
.trigger {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.55);
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.trigger:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.08);
border-color: rgba(148, 163, 184, 0.45);
}
.triggerOpen {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb) / 0.14);
}
.trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.swatchDot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12);
}
.popover {
position: fixed;
z-index: 1200;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--glass-border);
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
box-shadow: var(--glass-shadow);
}
.option {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 2px solid transparent;
background: transparent;
transition: border-color 0.15s, transform 0.15s;
}
.option:hover {
transform: scale(1.06);
}
.optionSelected {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb) / 0.12);
}
.optionDot {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12);
}
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
BUSINESS_PRIMARY_COLOR_IDS,
BUSINESS_PRIMARY_COLOR_PALETTE,
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
import styles from './PrimaryColorSwatchControl.module.css'
interface PrimaryColorSwatchControlProps {
value: BusinessPrimaryColorId
onChange: (value: BusinessPrimaryColorId) => void
disabled?: boolean
}
type PopoverPosition = {
top: number
left: number
}
export function PrimaryColorSwatchControl({
value,
onChange,
disabled = false,
}: PrimaryColorSwatchControlProps) {
const [open, setOpen] = useState(false)
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const tokens = BUSINESS_PRIMARY_COLOR_PALETTE[value]
function updatePopoverPosition() {
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPopoverPos({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
})
}
function toggleOpen() {
if (disabled) return
if (open) {
setOpen(false)
return
}
updatePopoverPosition()
setOpen(true)
}
function selectColor(colorId: BusinessPrimaryColorId) {
if (colorId !== value) {
onChange(colorId)
}
setOpen(false)
}
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node
if (wrapRef.current?.contains(target)) return
if (popoverRef.current?.contains(target)) return
setOpen(false)
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
function handleReposition() {
updatePopoverPosition()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
window.addEventListener('resize', handleReposition)
window.addEventListener('scroll', handleReposition, true)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
window.removeEventListener('resize', handleReposition)
window.removeEventListener('scroll', handleReposition, true)
}
}, [open])
return (
<div className={styles.wrap} ref={wrapRef}>
<button
ref={triggerRef}
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={toggleOpen}
disabled={disabled}
aria-label={`Dashboard primary color: ${tokens.label}`}
aria-haspopup="listbox"
aria-expanded={open}
title={`Theme: ${tokens.label}`}
>
<span
className={styles.swatchDot}
style={{ backgroundColor: tokens.primary }}
aria-hidden="true"
/>
</button>
{open &&
popoverPos &&
createPortal(
<div
ref={popoverRef}
className={styles.popover}
role="listbox"
aria-label="Choose primary color"
style={{
top: popoverPos.top,
left: popoverPos.left,
}}
>
{BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
const selected = colorId === value
return (
<button
key={colorId}
type="button"
role="option"
aria-selected={selected}
aria-label={option.label}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
onClick={() => selectColor(colorId)}
>
<span
className={styles.optionDot}
style={{ backgroundColor: option.primary }}
aria-hidden="true"
/>
</button>
)
})}
</div>,
document.body,
)}
</div>
)
}
@@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import styles from './RouteLoader.module.css'
export function ProtectedRoute() {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className={styles.loaderWrap}>
<div className={styles.loader} aria-label="Loading" />
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
return <Outlet />
}
@@ -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-rgb) / 0.15);
border-top-color: var(--primary);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,73 @@
.card {
display: flex;
flex-direction: column;
padding: 22px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
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(var(--primary-rgb) / 0.12) 100%);
border-radius: var(--radius-sm);
color: var(--primary);
margin-bottom: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.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: 18px;
}
.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, box-shadow 0.2s;
}
.card:hover .arrowBtn {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
@@ -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,101 @@
.sidebar {
position: fixed;
top: 0;
left: 0;
width: var(--sidebar-width);
height: 100vh;
display: flex;
flex-direction: column;
padding: 20px 12px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
z-index: 100;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 24px;
padding: 4px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
}
.brandLogo {
display: block;
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
background: transparent;
}
.brandText {
display: flex;
flex-direction: column;
min-width: 0;
}
.brandDomain {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.brandName {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
}
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
padding-right: 2px;
}
.navItem {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s ease;
width: 100%;
text-align: left;
}
.navItem:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.navItem.active {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
}
.footer {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
flex-shrink: 0;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
}
@@ -0,0 +1,69 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { Home, Building2, Users, Globe, HelpCircle, LogOut } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
const navItems = [
{ icon: Home, label: 'Home', to: '/' },
{ icon: Building2, label: 'Businesses', to: '/businesses' },
{ icon: Users, label: 'Users', to: '/users' },
{ icon: Globe, label: 'Websites', to: '/websites' },
]
const footerItems = [
{ icon: HelpCircle, label: 'Help Center' },
{ icon: LogOut, label: 'Logout' },
]
export function Sidebar() {
const navigate = useNavigate()
const { logout } = useAuth()
return (
<aside className={styles.sidebar}>
<div className={styles.brand}>
<img src={meshkeeLogo} alt="Meshkee" className={styles.brandLogo} />
<div className={styles.brandText}>
<span className={styles.brandDomain}>Super Admin</span>
<span className={styles.brandName}>Meshkee.app</span>
</div>
</div>
<nav className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.label}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.active : ''}`
}
>
<item.icon size={20} />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className={styles.footer}>
{footerItems.map(({ icon: Icon, label }) => (
<button
key={label}
type="button"
className={styles.navItem}
onClick={() => {
if (label === 'Logout') {
logout()
navigate('/login')
}
}}
>
<Icon size={20} />
<span>{label}</span>
</button>
))}
</div>
</aside>
)
}
@@ -0,0 +1,80 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 300;
display: flex;
flex-direction: column;
align-items: flex-end;
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,34 @@
.switch {
position: relative;
width: 38px;
height: 22px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.45);
transition: background 0.2s;
flex-shrink: 0;
}
.switch.on {
background: #16a34a;
}
.thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform 0.2s;
}
.switch.on .thumb {
transform: translateX(16px);
}
.switch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,24 @@
import styles from './ToggleSwitch.module.css'
interface ToggleSwitchProps {
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
ariaLabel?: string
}
export function ToggleSwitch({ checked, onChange, disabled, ariaLabel }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? (checked ? 'Enabled' : 'Disabled')}
className={`${styles.switch} ${checked ? styles.on : ''}`}
onClick={() => onChange(!checked)}
disabled={disabled}
>
<span className={styles.thumb} />
</button>
)
}
@@ -0,0 +1,51 @@
.wrap {
position: relative;
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 10;
max-height: 180px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
}
.item,
.empty {
width: 100%;
padding: 10px 12px;
text-align: left;
font-size: 13px;
color: var(--text-primary);
}
.item {
transition: background 0.15s;
}
.item:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.empty {
color: var(--text-muted);
}
.selected {
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.hint {
font-size: 11px;
color: var(--text-muted);
}
@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react'
import { isAbortError } from '../lib/api'
import { searchUsers } from '../services/userService'
import type { UserSearchItem } from '../types/user'
import styles from './UserSearchInput.module.css'
const DEFAULT_MIN_CHARS = 2
export interface UserSearchInputProps {
id?: string
label?: string
placeholder?: string
disabled?: boolean
minChars?: number
selectedUser: UserSearchItem | null
onSelect: (user: UserSearchItem | null) => void
}
export function UserSearchInput({
id = 'user-search',
label = 'User',
placeholder = 'Search by name, email, or phone',
disabled = false,
minChars = DEFAULT_MIN_CHARS,
selectedUser,
onSelect,
}: UserSearchInputProps) {
const [query, setQuery] = useState(selectedUser?.label ?? '')
const [results, setResults] = useState<UserSearchItem[]>([])
const [searching, setSearching] = useState(false)
useEffect(() => {
setQuery(selectedUser?.label ?? '')
}, [selectedUser])
useEffect(() => {
if (query.trim().length < minChars || selectedUser) {
setResults([])
setSearching(false)
return
}
const controller = new AbortController()
const timer = setTimeout(() => {
setSearching(true)
void searchUsers(query.trim(), 20, controller.signal)
.then((result) => {
if (!controller.signal.aborted) setResults(result.items)
})
.catch((err) => {
if (!isAbortError(err) && !controller.signal.aborted) {
setResults([])
}
})
.finally(() => {
if (!controller.signal.aborted) setSearching(false)
})
}, 300)
return () => {
clearTimeout(timer)
controller.abort()
}
}, [query, minChars, selectedUser])
const showDropdown = query.trim().length >= minChars && !selectedUser
function handleQueryChange(value: string) {
setQuery(value)
if (selectedUser && value !== selectedUser.label) {
onSelect(null)
}
}
function pickUser(user: UserSearchItem) {
onSelect(user)
setQuery(user.label)
setResults([])
}
return (
<div>
<label htmlFor={id}>{label}</label>
<div className={styles.wrap}>
<input
id={id}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
disabled={disabled}
/>
{showDropdown && (
<div className={styles.dropdown}>
{searching ? (
<div className={styles.empty}>Searching...</div>
) : results.length === 0 ? (
<div className={styles.empty}>No users found.</div>
) : (
results.map((user) => (
<button
key={user.id}
type="button"
className={styles.item}
onClick={() => pickUser(user)}
>
{user.label}
</button>
))
)}
</div>
)}
</div>
{query.trim().length > 0 && query.trim().length < minChars && (
<div className={styles.hint}>Type at least {minChars} characters to search.</div>
)}
{selectedUser && <div className={styles.selected}>Selected: {selectedUser.label}</div>}
</div>
)
}
@@ -0,0 +1,96 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { ApiError, getAccessToken, isAbortError } from '../lib/api'
import { fetchCurrentUser, login as loginRequest, logout as logoutRequest } from '../services/authService'
import type { AuthUser } from '../types/auth'
interface AuthContextValue {
user: AuthUser | null
isLoading: boolean
login: (cellNumber: string, password: string) => Promise<void>
logout: () => void
setUser: (user: AuthUser) => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)
const logout = useCallback(() => {
logoutRequest()
setUser(null)
}, [])
useEffect(() => {
const controller = new AbortController()
async function init() {
if (!getAccessToken()) {
if (!controller.signal.aborted) setIsLoading(false)
return
}
try {
const { user: currentUser } = await fetchCurrentUser(controller.signal)
if (controller.signal.aborted) return
if (currentUser.dashboard !== 'super_admin') {
logout()
return
}
setUser(currentUser)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
logout()
} finally {
if (!controller.signal.aborted) setIsLoading(false)
}
}
void init()
return () => {
controller.abort()
}
}, [logout])
const login = useCallback(
async (cellNumber: string, password: string) => {
const data = await loginRequest(cellNumber, password)
if (data.user.dashboard !== 'super_admin') {
logoutRequest()
throw new ApiError(
'This account does not have super admin access. Use the correct dashboard for your role.',
403,
)
}
setUser(data.user)
},
[],
)
const value = useMemo(
() => ({ user, isLoading, login, logout, setUser }),
[user, isLoading, login, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
@@ -0,0 +1,98 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import styles from '../components/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
}
+40
View File
@@ -0,0 +1,40 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
+158
View File
@@ -0,0 +1,158 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-gradient-start: #dce8ff;
--bg-gradient-mid: #e8eeff;
--bg-gradient-end: #f4f7ff;
--glass-bg: rgba(255, 255, 255, 0.42);
--glass-bg-strong: rgba(255, 255, 255, 0.62);
--glass-border: rgba(255, 255, 255, 0.75);
--glass-shadow: 0 8px 32px rgba(30, 58, 138, 0.1);
--primary: #0a1628;
--primary-rgb: 10, 22, 40;
--primary-light: #d4dce8;
--primary-soft: #9eb4cc;
--primary-dark: #050b14;
--primary-glow: #1a3a6b;
--accent-indigo: #6366f1;
--accent-sky: #38bdf8;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--sidebar-width: 260px;
--radius: 16px;
--radius-sm: 12px;
--blur-glass: 28px;
--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,
body,
#root {
height: 100%;
}
body {
font-family: var(--font-en);
color: var(--text-primary);
background:
radial-gradient(ellipse 80% 60% at 10% 0%, rgba(99, 102, 241, 0.18) 0%, transparent 55%),
radial-gradient(ellipse 70% 50% at 90% 100%, rgba(56, 189, 248, 0.16) 0%, transparent 50%),
linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-mid) 45%, var(--bg-gradient-end) 100%);
background-attachment: fixed;
-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:not([multiple]) {
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.65);
border: 1px solid rgba(148, 163, 184, 0.35);
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.14);
}
select[multiple] {
appearance: auto;
-webkit-appearance: listbox;
-moz-appearance: listbox;
background-image: none;
padding: 8px 10px;
line-height: 1.4;
}
select[multiple] option {
padding: 4px 6px;
}
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.14);
}
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder {
font-family: var(--font-en);
}
[dir='rtl'],
:lang(fa),
.faText {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
}
+122
View File
@@ -0,0 +1,122 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
const ACCESS_TOKEN_KEY = 'meshkee_access_token'
const REFRESH_TOKEN_KEY = 'meshkee_refresh_token'
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'
}
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY)
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
}
export function clearTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
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
}
async function refreshAccessToken() {
const refreshToken = getRefreshToken()
if (!refreshToken) {
return false
}
const response = await fetch(`${API_BASE_URL}/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
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown
auth?: boolean
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): 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(`${API_BASE_URL}${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
}
+30
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
/** Super admin host (meshkee.app in production). Defaults to current hostname. */
export function getAdminDomain(): string {
const fromEnv = import.meta.env.VITE_ADMIN_DOMAIN
if (fromEnv) return fromEnv
return window.location.hostname
}
export function isAllowedAdminHost(hostname = window.location.hostname): boolean {
return hostname === getAdminDomain()
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './fonts/iranyekan.css'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,520 @@
.filtersPanel {
padding: 14px 16px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.filtersTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 10px;
}
.filtersGrid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 10px 12px;
align-items: end;
}
.filtersInputs {
grid-column: span 10;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 10px 12px;
}
.filterActions {
grid-column: span 2;
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldCol3 {
grid-column: span 3;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.helperText {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.field input,
.field select,
.field textarea {
width: 100%;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
color: var(--text-primary);
background: rgba(255, 255, 255, 0.65);
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 select {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
}
.field input,
.field select {
min-height: var(--field-height);
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.iconActionBtn {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: background 0.2s, color 0.2s, transform 0.15s;
}
.iconActionBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.iconActionBtnPrimary {
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
}
.iconActionBtnPrimary:hover:not(:disabled) {
color: white;
}
.iconActionBtnGhost {
background: rgba(var(--primary-rgb) / 0.06);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
color: var(--primary);
}
.iconActionBtnGhost:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
}
.iconActionBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.actionsRow {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.btnPrimary {
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
}
.btnPrimary:hover:not(:disabled) {
transform: translateY(-1px);
}
.btnGhost {
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
}
.btnGhost:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.12);
transform: translateY(-1px);
}
.tablePanel {
margin-top: 12px;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.tableHeaderTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.table {
width: 100%;
border-collapse: collapse;
}
.th,
.td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
}
.th {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
}
.thActions,
.tdActions {
text-align: right;
width: 1%;
white-space: nowrap;
}
.thTheme,
.tdTheme {
width: 1%;
white-space: nowrap;
text-align: center;
}
.tdTheme {
padding-left: 8px;
padding-right: 8px;
}
.td {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.ownerCell {
display: flex;
flex-direction: column;
gap: 2px;
}
.ownerName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
}
.ownerPhone {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
line-height: 1.3;
}
.subText {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
}
.domainCell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sslOk {
color: #16a34a;
flex-shrink: 0;
}
.sslWarn {
color: #f59e0b;
flex-shrink: 0;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.toggleInActions {
margin-right: 4px;
}
.controlBtn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.controlBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controlBtnDanger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.pagination {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagerBtns {
display: flex;
gap: 8px;
align-items: center;
}
.pageBtn {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.35);
color: var(--text-secondary);
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.pageBtnActive {
border-color: rgba(var(--primary-rgb) / 0.35);
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
}
.fab {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 40;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.35);
transition: transform 0.15s, box-shadow 0.15s;
}
.fab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.4);
}
.formGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.sectionLabel {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
.formFull {
grid-column: 1 / -1;
}
.field textarea {
width: 100%;
min-height: 96px;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
color: var(--text-primary);
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.userSearchWrap {
position: relative;
}
.userSearchDropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 10;
max-height: 180px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
}
.userSearchItem,
.userSearchEmpty {
width: 100%;
padding: 10px 12px;
text-align: left;
font-size: 13px;
color: var(--text-primary);
}
.userSearchItem {
transition: background 0.15s;
}
.userSearchItem:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.userSearchItemActive {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
font-weight: 600;
}
.userSearchEmpty {
color: var(--text-muted);
}
.selectedOwner {
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.hint {
font-size: 11px;
color: var(--text-muted);
}
@media (max-width: 1100px) {
.filtersInputs {
grid-column: span 12;
}
.filterActions {
grid-column: span 12;
justify-content: flex-end;
}
.fieldCol3 {
grid-column: span 6;
}
}
@media (max-width: 768px) {
.filtersInputs {
grid-template-columns: 1fr;
}
.fieldCol3 {
grid-column: span 1;
}
.formGrid {
grid-template-columns: 1fr;
}
.fab {
right: 16px;
bottom: 16px;
}
.td,
.th {
padding: 10px 10px;
}
}
@@ -0,0 +1,977 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
Globe,
Lock,
Pencil,
Plus,
RotateCcw,
Search,
Trash2,
UserCog,
Users,
} from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import type { BusinessesListResponse, BusinessListItem } from '../types/business'
import type { BusinessCategory } from '../types/category'
import type { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api'
import {
addBusinessDomain,
createBusiness,
listBusinesses,
removeBusiness,
setBusinessActive,
updateBusiness,
updateBusinessDomain,
} from '../services/businessService'
import { listBusinessCategories } from '../services/categoryService'
import { Modal } from '../components/Modal'
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
import { PrimaryColorPicker } from '../components/PrimaryColorPicker'
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
import { useToast } from '../context/ToastContext'
import {
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
normalizeBusinessPrimaryColorId,
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
import {
getBusinessSettings,
updateBusinessPrimaryColor,
} from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 10
function formatDate(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
export function BusinessesPage() {
const { showToast } = useToast()
const navigate = useNavigate()
const [data, setData] = useState<BusinessesListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [categories, setCategories] = useState<BusinessCategory[]>([])
const [appliedFilters, setAppliedFilters] = useState<Omit<ListBusinessesParams, 'page' | 'pageSize'>>(
{},
)
const [page, setPage] = useState(1)
const [pageSize] = useState(DEFAULT_PAGE_SIZE)
const [draftName, setDraftName] = useState('')
const [draftDomain, setDraftDomain] = useState('')
const [draftCategory, setDraftCategory] = useState('')
const [editOpen, setEditOpen] = useState(false)
const [editBusiness, setEditBusiness] = useState<BusinessListItem | null>(null)
const [editName, setEditName] = useState('')
const [editPrimaryColor, setEditPrimaryColor] = useState<BusinessPrimaryColorId>(
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
)
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [domainOpen, setDomainOpen] = useState(false)
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
const [domainId, setDomainId] = useState<number | null>(null)
const [domainHost, setDomainHost] = useState('')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createNameFa, setCreateNameFa] = useState('')
const [createAbout, setCreateAbout] = useState('')
const [createCategoryIds, setCreateCategoryIds] = useState<number[]>([])
const [createOwnerFirstName, setCreateOwnerFirstName] = useState('')
const [createOwnerLastName, setCreateOwnerLastName] = useState('')
const [createOwnerCell, setCreateOwnerCell] = useState('')
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
async function fetchList() {
setLoading(true)
setError('')
try {
const result = await listBusinesses({
page,
pageSize,
...appliedFilters,
})
setData(result)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
} finally {
setLoading(false)
}
}
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const [result, categoryResult] = await Promise.all([
listBusinesses(
{
page,
pageSize,
...appliedFilters,
},
controller.signal,
),
listBusinessCategories(controller.signal),
])
if (controller.signal.aborted) return
setData(result)
setCategories(categoryResult.items)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [page, pageSize, appliedFilters.name, appliedFilters.domain, appliedFilters.category])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / pageSize))
}, [data?.total, pageSize])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * pageSize + 1
}, [data, page, pageSize])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * pageSize)
}, [data, page, pageSize])
const categoryOptions = useMemo(() => flattenBusinessCategories(categories), [categories])
function applyFilters() {
setPage(1)
setAppliedFilters({
...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftDomain.trim() ? { domain: draftDomain.trim() } : {}),
...(draftCategory.trim() ? { category: draftCategory.trim() } : {}),
})
}
function clearFilters() {
setDraftName('')
setDraftDomain('')
setDraftCategory('')
setPage(1)
setAppliedFilters({})
}
function openEdit(b: BusinessListItem) {
setEditBusiness(b)
setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditOpen(true)
setEditLoadingSettings(true)
const controller = new AbortController()
void getBusinessSettings(b.id, controller.signal)
.then((data) => {
setEditPrimaryColor(data.settings.branding.primaryColor)
})
.catch((err) => {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
})
.finally(() => {
if (!controller.signal.aborted) setEditLoadingSettings(false)
})
}
function goToBusinessUsers(b: BusinessListItem, membership: 'staff' | 'all') {
const params = new URLSearchParams({
businessId: String(b.id),
membership,
businessName: b.name,
})
navigate(`/users?${params.toString()}`)
}
async function handlePrimaryColorChange(
b: BusinessListItem,
primaryColor: BusinessPrimaryColorId,
) {
const currentColor = normalizeBusinessPrimaryColorId(b.primaryColor)
if (currentColor === primaryColor) return
setSavingColorId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, primaryColor } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditPrimaryColor(primaryColor)
}
try {
await updateBusinessPrimaryColor(b.id, primaryColor)
showToast(`Theme updated for "${b.name}".`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, primaryColor: currentColor } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditPrimaryColor(currentColor)
}
showToast(
err instanceof ApiError ? err.message : 'Unable to update theme.',
'error',
)
} finally {
setSavingColorId(null)
}
}
async function submitEdit() {
if (!editBusiness) return
setEditSubmitting(true)
setError('')
try {
await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === editBusiness.id
? { ...item, name: editName.trim(), primaryColor: editPrimaryColor }
: item,
),
}
})
setEditOpen(false)
setEditBusiness(null)
showToast('Business updated.', 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update business.')
} finally {
setEditSubmitting(false)
}
}
function openDomainEdit(b: BusinessListItem) {
setDomainBusiness(b)
setDomainId(b.domainId)
setDomainHost(b.domain ?? '')
setDomainOpen(true)
}
async function submitDomain() {
if (!domainBusiness) return
const host = domainHost.trim()
if (!host) return
setDomainSubmitting(true)
setError('')
try {
if (domainId) {
await updateBusinessDomain(domainBusiness.id, domainId, { host })
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === domainBusiness.id ? { ...item, domain: host } : item,
),
}
})
showToast(`Domain updated to "${host}".`, 'success')
} else {
const created = (await addBusinessDomain(domainBusiness.id, {
host,
isPrimary: true,
})) as { id: number; host: string; sslEnabled: boolean }
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === domainBusiness.id
? {
...item,
domainId: created.id,
domain: created.host,
sslEnabled: created.sslEnabled ?? false,
}
: item,
),
}
})
showToast(`Domain "${host}" added.`, 'success')
}
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally {
setDomainSubmitting(false)
}
}
function resetCreateForm() {
setCreateName('')
setCreateNameFa('')
setCreateAbout('')
setCreateCategoryIds([])
setCreateOwnerFirstName('')
setCreateOwnerLastName('')
setCreateOwnerCell('')
setCreateOwnerPassword('')
}
function openCreate() {
resetCreateForm()
setCreateOpen(true)
}
async function submitCreate() {
const ownerCellNumber = toE164CellNumber(createOwnerCell.trim())
if (!ownerCellNumber) return
setCreateSubmitting(true)
setError('')
try {
await createBusiness({
name: createName.trim(),
nameFa: createNameFa.trim(),
about: createAbout.trim() || undefined,
categoryIds: createCategoryIds,
ownerFirstName: createOwnerFirstName.trim(),
ownerLastName: createOwnerLastName.trim(),
ownerCellNumber,
ownerPassword: createOwnerPassword,
})
setCreateOpen(false)
resetCreateForm()
showToast(`"${createName.trim()}" has been created.`, 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to create business.')
} finally {
setCreateSubmitting(false)
}
}
async function handleToggleActive(b: BusinessListItem, isActive: boolean) {
setTogglingId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === b.id ? { ...item, isActive } : item)),
}
})
try {
await setBusinessActive(b.id, { isActive })
showToast(`"${b.name}" has been ${isActive ? 'enabled' : 'disabled'}.`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, isActive: !isActive } : item,
),
}
})
setError(err instanceof ApiError ? err.message : 'Unable to update business status.')
} finally {
setTogglingId(null)
}
}
async function confirmRemove() {
if (!removeTarget) return
setError('')
try {
await removeBusiness(removeTarget.id)
setRemoveTarget(null)
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove business.')
}
}
const canSubmitCreate =
createName.trim().length >= 2 &&
createNameFa.trim().length >= 2 &&
createCategoryIds.length > 0 &&
createOwnerFirstName.trim().length >= 2 &&
createOwnerLastName.trim().length >= 2 &&
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
createOwnerPassword.length >= 8
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Businesses</h2>
<p className={pageStyles.pageSubtitle}>
Manage businesses, domains, and SSL settings.
</p>
</div>
</div>
<div className={styles.filtersPanel}>
<div className={styles.filtersTitle}>Filters</div>
<div className={styles.filtersGrid}>
<div className={styles.filtersInputs}>
<div className={`${styles.field} ${styles.fieldCol3}`}>
<label htmlFor="filter-name">Name</label>
<input
id="filter-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by business name"
/>
</div>
<div className={`${styles.field} ${styles.fieldCol3}`}>
<label htmlFor="filter-domain">Domain</label>
<input
id="filter-domain"
value={draftDomain}
onChange={(e) => setDraftDomain(e.target.value)}
placeholder="shop-a.local"
/>
</div>
<div className={`${styles.field} ${styles.fieldCol3}`}>
<label htmlFor="filter-category">Category</label>
<input
id="filter-category"
value={draftCategory}
onChange={(e) => setDraftCategory(e.target.value)}
placeholder="e.g. electronics / phones"
/>
</div>
</div>
<div className={styles.filterActions}>
<button
type="button"
className={`${styles.iconActionBtn} ${styles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${styles.iconActionBtn} ${styles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Business list</div>
<div className={styles.meta}>
{data ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
' '
)}
</div>
</div>
{error && (
<div style={{ padding: 16 }}>
<div className={styles.meta} style={{ color: '#b91c1c' }}>
{error}
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Name</th>
<th className={styles.th}>Date created</th>
<th className={styles.th}>Domain</th>
<th className={styles.th}>Owner</th>
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={6}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={6}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((b) => (
<tr key={b.id}>
<td className={styles.td}>{b.name}</td>
<td className={styles.td}>{formatDate(b.createdAt)}</td>
<td className={styles.td}>
{b.domain ? (
<div className={styles.domainCell}>
<span>{b.domain}</span>
{b.sslEnabled ? (
<Lock size={16} className={styles.sslOk} aria-label="SSL enabled" />
) : (
<AlertTriangle
size={16}
className={styles.sslWarn}
aria-label="SSL not enabled"
/>
)}
</div>
) : (
<span className={styles.subText}>No domain</span>
)}
</td>
<td className={styles.td}>
<div className={styles.ownerCell}>
<div className={styles.ownerName}>{b.ownerName ?? '—'}</div>
<div className={styles.ownerPhone}>
{b.ownerCellNumber ? formatCellForDisplay(b.ownerCellNumber) : '—'}
</div>
</div>
</td>
<td className={`${styles.td} ${styles.tdTheme}`}>
<PrimaryColorSwatchControl
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
disabled={savingColorId === b.id}
onChange={(primaryColor) =>
void handlePrimaryColorChange(b, primaryColor)
}
/>
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<span className={styles.toggleInActions}>
<ToggleSwitch
checked={b.isActive}
disabled={togglingId === b.id}
ariaLabel={`${b.isActive ? 'Disable' : 'Enable'} ${b.name}`}
onChange={(isActive) => void handleToggleActive(b, isActive)}
/>
</span>
<button
type="button"
className={styles.controlBtn}
onClick={() => goToBusinessUsers(b, 'staff')}
title="Staff"
aria-label="View staff"
>
<UserCog size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => goToBusinessUsers(b, 'all')}
title="All users"
aria-label="View all users"
>
<Users size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => openEdit(b)}
title="Edit"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={() => openDomainEdit(b)}
title="Edit domain"
aria-label="Edit domain"
>
<Globe size={16} />
</button>
<button
type="button"
className={`${styles.controlBtn} ${styles.controlBtnDanger}`}
onClick={() => setRemoveTarget(b)}
title="Remove"
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<button
type="button"
className={styles.fab}
onClick={openCreate}
aria-label="Add business"
title="Add business"
>
<Plus size={24} />
</button>
<Modal
open={editOpen}
title="Edit business"
onClose={() => {
setEditOpen(false)
setEditBusiness(null)
}}
>
<div className={styles.field}>
<label htmlFor="edit-name">Business name</label>
<input
id="edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
disabled={editSubmitting}
/>
</div>
<div className={styles.field}>
<label>Dashboard primary color</label>
{editLoadingSettings ? (
<p className={styles.helperText}>Loading theme...</p>
) : (
<PrimaryColorPicker
value={editPrimaryColor}
onChange={setEditPrimaryColor}
disabled={editSubmitting}
/>
)}
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => setEditOpen(false)}
disabled={editSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitEdit()}
disabled={editSubmitting || !editName.trim() || editLoadingSettings}
>
Save
</button>
</div>
</Modal>
<Modal
open={domainOpen}
title={domainId ? 'Edit domain' : 'Add domain'}
onClose={() => {
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
}}
>
<div className={styles.field}>
<label htmlFor="domain-host">Domain</label>
<input
id="domain-host"
value={domainHost}
onChange={(e) => setDomainHost(e.target.value)}
placeholder="shop-a.local"
disabled={domainSubmitting}
/>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => setDomainOpen(false)}
disabled={domainSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitDomain()}
disabled={domainSubmitting || !domainHost.trim()}
>
Save
</button>
</div>
</Modal>
<Modal
open={createOpen}
title="Add business"
wide
onClose={() => {
setCreateOpen(false)
resetCreateForm()
}}
>
<div className={styles.formGrid}>
<div className={styles.field}>
<label htmlFor="create-name-en">Name (EN)</label>
<input
id="create-name-en"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="New Shop"
disabled={createSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="create-name-fa">Name (FA)</label>
<input
id="create-name-fa"
className="faText"
value={createNameFa}
onChange={(e) => setCreateNameFa(e.target.value)}
placeholder="نام فارسی"
dir="rtl"
disabled={createSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<label htmlFor="create-categories">Categories</label>
<MultiSelectDropdown
id="create-categories"
options={categoryOptions}
value={createCategoryIds}
onChange={setCreateCategoryIds}
placeholder="Select categories"
disabled={createSubmitting || categories.length === 0}
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<div className={styles.sectionLabel}>Owner</div>
</div>
<div className={styles.field}>
<label htmlFor="create-owner-first-name">First name</label>
<input
id="create-owner-first-name"
value={createOwnerFirstName}
onChange={(e) => setCreateOwnerFirstName(e.target.value)}
disabled={createSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="create-owner-last-name">Last name</label>
<input
id="create-owner-last-name"
value={createOwnerLastName}
onChange={(e) => setCreateOwnerLastName(e.target.value)}
disabled={createSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<label htmlFor="create-owner-cell">Cell number</label>
<input
id="create-owner-cell"
value={createOwnerCell}
onChange={(e) => setCreateOwnerCell(e.target.value)}
placeholder="0912..."
name="owner-cell"
autoComplete="off"
disabled={createSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<label htmlFor="create-owner-password">Password</label>
<input
id="create-owner-password"
type="password"
value={createOwnerPassword}
onChange={(e) => setCreateOwnerPassword(e.target.value)}
placeholder="Min. 8 characters"
name="owner-password"
autoComplete="new-password"
disabled={createSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.formFull}`}>
<label htmlFor="create-about">About business</label>
<textarea
id="create-about"
value={createAbout}
onChange={(e) => setCreateAbout(e.target.value)}
placeholder="Short description about the business"
disabled={createSubmitting}
/>
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={() => setCreateOpen(false)}
disabled={createSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitCreate()}
disabled={createSubmitting || !canSubmitCreate}
>
Create
</button>
</div>
</Modal>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove business?"
message={
removeTarget
? `Are you sure you want to remove "${removeTarget.name}"? This action cannot be undone.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
</main>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { CalendarDays, Building2, Users, Globe } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { SectionCard } from '../components/SectionCard'
import styles from '../components/PageContent.module.css'
const sections = [
{
icon: Building2,
title: 'Businesses',
description: 'Manage all businesses, their plans, and account status.',
linkText: 'View businesses',
href: '/businesses',
},
{
icon: Users,
title: 'Users',
description: 'View and manage platform users, roles, and permissions.',
linkText: 'View users',
href: '/users',
},
{
icon: Globe,
title: 'Websites',
description: 'Monitor and manage all websites across the platform.',
linkText: 'View websites',
href: '/websites',
},
]
function getFormattedDate() {
return new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
weekday: 'long',
}).format(new Date())
}
export function HomePage() {
const { user } = useAuth()
const firstName = user?.firstName || 'Admin'
return (
<main className={styles.content}>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>
Welcome back, {firstName}! <span aria-hidden="true">👋</span>
</h2>
<p className={styles.pageSubtitle}>
Here&apos;s what&apos;s happening across the Meshkee platform today.
</p>
</div>
<div className={styles.dateBadge}>
<CalendarDays size={16} />
<span>{getFormattedDate()}</span>
</div>
</div>
<div className={styles.gridHome}>
{sections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
</main>
)
}
@@ -0,0 +1,278 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
position: relative;
overflow: hidden;
}
.bgOrbs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.orb1,
.orb2,
.orb3,
.orb4 {
position: absolute;
border-radius: 50%;
filter: blur(100px);
will-change: transform;
}
.orb1 {
width: 560px;
height: 560px;
background: radial-gradient(circle, #93c5fd 0%, #60a5fa 55%, transparent 72%);
opacity: 0.65;
top: -140px;
right: -120px;
animation: floatOrb1 18s ease-in-out infinite;
}
.orb2 {
width: 480px;
height: 480px;
background: radial-gradient(circle, #818cf8 0%, #6366f1 50%, transparent 70%);
opacity: 0.55;
bottom: -100px;
left: 15%;
animation: floatOrb2 22s ease-in-out infinite;
}
.orb3 {
width: 380px;
height: 380px;
background: radial-gradient(circle, #7dd3fc 0%, #38bdf8 55%, transparent 72%);
opacity: 0.5;
top: 38%;
left: -100px;
animation: floatOrb3 20s ease-in-out infinite;
}
.orb4 {
width: 320px;
height: 320px;
background: radial-gradient(circle, #c4b5fd 0%, #a78bfa 50%, transparent 70%);
opacity: 0.45;
top: 12%;
right: 28%;
animation: floatOrb4 24s ease-in-out infinite;
}
.card {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
padding: 36px 32px 32px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow:
0 20px 60px rgba(var(--primary-rgb) / 0.14),
0 0 0 1px rgba(var(--primary-rgb) / 0.2);
}
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 28px;
}
.logo {
display: block;
width: 44px;
height: 44px;
object-fit: contain;
background: transparent;
flex-shrink: 0;
}
.brandText {
display: flex;
flex-direction: column;
}
.domain {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.appName {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
}
.title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
margin-bottom: 6px;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
margin-bottom: 28px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.inputWrap {
position: relative;
display: flex;
align-items: center;
}
.inputIcon {
position: absolute;
left: 14px;
color: var(--text-secondary);
opacity: 1;
pointer-events: none;
stroke-width: 2.25;
}
.inputWrap input {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) 44px var(--field-padding-y) 42px;
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
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;
}
.inputWrap input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.14);
}
.togglePassword {
position: absolute;
right: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
opacity: 1;
padding: 4px;
transition: color 0.2s;
}
.togglePassword:hover {
color: var(--primary);
}
.submitBtn {
width: 100%;
padding: 13px;
font-size: 15px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 4px;
}
.submitBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(var(--primary-rgb) / 0.42);
}
.submitBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error {
padding: 12px 14px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
color: #b91c1c;
font-size: 13px;
line-height: 1.5;
}
@keyframes floatOrb1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-30px, 25px) scale(1.06); }
}
@keyframes floatOrb2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(35px, -20px) scale(1.05); }
}
@keyframes floatOrb3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, 30px) scale(1.08); }
}
@keyframes floatOrb4 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-25px, -15px) scale(1.04); }
}
@media (max-width: 480px) {
.card {
padding: 28px 20px 24px;
}
.title {
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.orb1,
.orb2,
.orb3,
.orb4 {
animation: none;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css'
export function LoginPage() {
const navigate = useNavigate()
const { login } = useAuth()
const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await login(cellNumber, password)
navigate('/')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to sign in. Check your connection and try again.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className={styles.page}>
<div className={styles.bgOrbs} aria-hidden="true">
<div className={styles.orb1} />
<div className={styles.orb2} />
<div className={styles.orb3} />
<div className={styles.orb4} />
</div>
<div className={styles.card}>
<div className={styles.brand}>
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
<div className={styles.brandText}>
<span className={styles.domain}>Super Admin</span>
<span className={styles.appName}>Meshkee.app</span>
</div>
</div>
<h1 className={styles.title}>Welcome back</h1>
<p className={styles.subtitle}>Sign in to the super admin panel</p>
<form className={styles.form} onSubmit={handleLogin} autoComplete="off">
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.field}>
<label htmlFor="login-phone">Mobile number</label>
<div className={styles.inputWrap}>
<Smartphone size={18} strokeWidth={2.25} className={styles.inputIcon} />
<input
id="login-phone"
type="tel"
inputMode="tel"
name="meshkee-admin-cell"
autoComplete="off"
placeholder="09121111111"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="login-password">Password</label>
<div className={styles.inputWrap}>
<Lock size={18} strokeWidth={2.25} className={styles.inputIcon} />
<input
id="login-password"
type={showPassword ? 'text' : 'password'}
name="meshkee-admin-password"
autoComplete="new-password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} strokeWidth={2.25} /> : <Eye size={18} strokeWidth={2.25} />}
</button>
</div>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
@@ -0,0 +1,145 @@
.form {
display: flex;
flex-direction: column;
gap: 24px;
}
.alertError,
.alertSuccess {
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
}
.alertError {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
color: #b91c1c;
}
.alertSuccess {
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.25);
color: #15803d;
}
.section {
padding: 28px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px 20px;
}
.fullWidth {
grid-column: 1 / -1;
}
.col3 {
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field textarea {
width: 100%;
padding: 12px 14px;
font-size: 14px;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.65);
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;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.readOnly {
background: rgba(241, 245, 249, 0.8);
color: var(--text-secondary);
cursor: not-allowed;
}
.actions {
display: flex;
justify-content: flex-end;
}
.saveBtn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 22px;
font-size: 14px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, 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;
}
.saveBtn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.38);
}
.saveBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
.section {
padding: 20px;
}
}
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(12, 1fr);
gap: 16px 20px;
}
.col3 {
grid-column: span 3;
}
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import { Save } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { ApiError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import { updateProfile } from '../services/profileService'
import type { ProfileFormData } from '../types/profile'
import { emptyProfile } from '../types/profile'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProfilePage.module.css'
function buildFormData(user: ReturnType<typeof useAuth>['user']): ProfileFormData {
return {
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
cellNumber: user ? formatCellForDisplay(user.cellNumber) : '',
email: user?.email ?? '',
about: user?.profile?.about ?? emptyProfile.about,
city: user?.profile?.city ?? emptyProfile.city,
address: user?.profile?.address ?? emptyProfile.address,
landline: user?.profile?.landline ?? emptyProfile.landline,
postalCode: user?.profile?.postalCode ?? emptyProfile.postalCode,
instagram: user?.profile?.instagram ?? emptyProfile.instagram,
telegramId: user?.profile?.telegramId ?? emptyProfile.telegramId,
linkedin: user?.profile?.linkedin ?? emptyProfile.linkedin,
}
}
export function ProfilePage() {
const { user, setUser } = useAuth()
const [form, setForm] = useState<ProfileFormData>(() => buildFormData(user))
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
setForm(buildFormData(user))
}, [user])
function updateField<K extends keyof ProfileFormData>(key: K, value: ProfileFormData[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSuccess('')
setIsSaving(true)
try {
const result = await updateProfile(form)
setUser(result.user)
setSuccess('Profile saved successfully.')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to save profile.')
} finally {
setIsSaving(false)
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Profile</h2>
<p className={pageStyles.pageSubtitle}>
Manage your personal information and contact details.
</p>
</div>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
{success && (
<div className={styles.alertSuccess} role="status">
{success}
</div>
)}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>General</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="firstName">Name</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => updateField('firstName', e.target.value)}
placeholder="First name"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="lastName">Last name</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => updateField('lastName', e.target.value)}
placeholder="Last name"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="cellNumber">Cell number</label>
<input
id="cellNumber"
type="tel"
value={form.cellNumber}
readOnly
className={styles.readOnly}
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => updateField('email', e.target.value)}
placeholder="you@example.com"
/>
</div>
<div className={`${styles.field} ${styles.fullWidth}`}>
<label htmlFor="about">About yourself</label>
<textarea
id="about"
value={form.about}
onChange={(e) => updateField('about', e.target.value)}
placeholder="Tell us a little about yourself..."
rows={4}
/>
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Address</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="city">City</label>
<input
id="city"
type="text"
value={form.city}
onChange={(e) => updateField('city', e.target.value)}
placeholder="City"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="postalCode">Postal code</label>
<input
id="postalCode"
type="text"
value={form.postalCode}
onChange={(e) => updateField('postalCode', e.target.value)}
placeholder="1234567890"
/>
</div>
<div className={`${styles.field} ${styles.fullWidth}`}>
<label htmlFor="address">Address</label>
<input
id="address"
type="text"
value={form.address}
onChange={(e) => updateField('address', e.target.value)}
placeholder="Street address"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="landline">Landline</label>
<input
id="landline"
type="tel"
value={form.landline}
onChange={(e) => updateField('landline', e.target.value)}
placeholder="02112345678"
/>
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Social media</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="instagram">Instagram</label>
<input
id="instagram"
type="text"
value={form.instagram}
onChange={(e) => updateField('instagram', e.target.value)}
placeholder="@username"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="telegramId">Telegram ID</label>
<input
id="telegramId"
type="text"
value={form.telegramId}
onChange={(e) => updateField('telegramId', e.target.value)}
placeholder="@username"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="linkedin">LinkedIn</label>
<input
id="linkedin"
type="url"
value={form.linkedin}
onChange={(e) => updateField('linkedin', e.target.value)}
placeholder="https://linkedin.com/in/username"
/>
</div>
</div>
</section>
<div className={styles.actions}>
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
<Save size={18} />
{isSaving ? 'Saving...' : 'Save changes'}
</button>
</div>
</form>
</main>
)
}
@@ -0,0 +1,86 @@
.roleBadge {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
}
.inactiveRow {
opacity: 0.55;
}
.statusInactive {
color: #b91c1c;
font-size: 11px;
font-weight: 600;
}
.businessBanner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
color: var(--text);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.16);
}
.businessBannerClear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
color: var(--text-muted);
background: transparent;
cursor: pointer;
}
.businessBannerClear:hover {
color: var(--text);
background: rgba(15, 23, 42, 0.06);
}
.roleList {
display: flex;
flex-direction: column;
gap: 8px;
}
.roleOption {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(255, 255, 255, 0.45);
cursor: pointer;
}
.roleOption:has(input:checked) {
border-color: rgba(var(--primary-rgb) / 0.35);
background: rgba(var(--primary-rgb) / 0.08);
}
.roleOptionLabel {
font-size: 13px;
font-weight: 600;
}
.roleHint {
margin-top: 12px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
.daysOk {
color: var(--text-primary);
font-weight: 500;
}
.daysWarn {
color: #d97706;
font-weight: 600;
}
.daysExpired {
color: #b91c1c;
font-weight: 600;
}
.inactiveRow {
opacity: 0.55;
}
+527
View File
@@ -0,0 +1,527 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Lock, Pencil, RotateCcw, Search, Trash2, Unlock } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import type { DomainListItem, DomainsListResponse } from '../types/domain'
import type { ListDomainsParams } from '../services/domainService'
import { ApiError, isAbortError } from '../lib/api'
import {
listDomains,
removeDomain,
setDomainActive,
setDomainSsl,
updateDomain,
} from '../services/domainService'
import { useToast } from '../context/ToastContext'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './WebsitesPage.module.css'
const PAGE_SIZE = 10
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function daysUntilExpiry(expiresAt: string | null) {
if (!expiresAt) return null
const diff = new Date(expiresAt).getTime() - Date.now()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
function formatDaysLeft(expiresAt: string | null) {
const days = daysUntilExpiry(expiresAt)
if (days === null) return '—'
if (days < 0) return 'Expired'
if (days === 0) return 'Today'
if (days === 1) return '1 day'
return `${days} days`
}
function daysLeftClass(expiresAt: string | null) {
const days = daysUntilExpiry(expiresAt)
if (days === null) return styles.daysOk
if (days < 0) return styles.daysExpired
if (days <= 30) return styles.daysWarn
return styles.daysOk
}
export function WebsitesPage() {
const { showToast } = useToast()
const [data, setData] = useState<DomainsListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<Omit<ListDomainsParams, 'page' | 'pageSize'>>({})
const [page, setPage] = useState(1)
const [draftName, setDraftName] = useState('')
const [editOpen, setEditOpen] = useState(false)
const [editDomain, setEditDomain] = useState<DomainListItem | null>(null)
const [editHost, setEditHost] = useState('')
const [editExpiresAt, setEditExpiresAt] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const result = await listDomains(
{ page, pageSize: PAGE_SIZE, ...appliedFilters },
controller.signal,
)
if (controller.signal.aborted) return
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load domains.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [page, appliedFilters.name])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
}, [data, page])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
function applyFilters() {
setPage(1)
setAppliedFilters(draftName.trim() ? { name: draftName.trim() } : {})
}
function clearFilters() {
setDraftName('')
setPage(1)
setAppliedFilters({})
}
function openEdit(domain: DomainListItem) {
setEditDomain(domain)
setEditHost(domain.host)
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
setEditOpen(true)
}
async function submitEdit() {
if (!editDomain) return
setEditSubmitting(true)
setError('')
try {
await updateDomain(editDomain.id, {
host: editHost.trim(),
...(editExpiresAt ? { expiresAt: new Date(editExpiresAt).toISOString() } : {}),
})
setEditOpen(false)
setEditDomain(null)
showToast(`"${editHost.trim()}" has been updated.`, 'success')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === editDomain.id
? {
...item,
host: editHost.trim(),
expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : item.expiresAt,
}
: item,
),
}
})
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update domain.')
} finally {
setEditSubmitting(false)
}
}
async function handleToggleActive(domain: DomainListItem, isActive: boolean) {
setTogglingActiveId(domain.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === domain.id ? { ...item, isActive } : item)),
}
})
try {
await setDomainActive(domain.id, isActive)
showToast(`"${domain.host}" has been ${isActive ? 'enabled' : 'disabled'}.`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === domain.id ? { ...item, isActive: !isActive } : item,
),
}
})
setError(err instanceof ApiError ? err.message : 'Unable to update domain status.')
} finally {
setTogglingActiveId(null)
}
}
async function handleToggleSsl(domain: DomainListItem, sslEnabled: boolean) {
setTogglingSslId(domain.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === domain.id ? { ...item, sslEnabled } : item)),
}
})
try {
await setDomainSsl(domain.id, sslEnabled)
showToast(
`SSL ${sslEnabled ? 'enabled' : 'disabled'} for "${domain.host}".`,
'success',
)
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === domain.id ? { ...item, sslEnabled: !sslEnabled } : item,
),
}
})
setError(err instanceof ApiError ? err.message : 'Unable to update SSL status.')
} finally {
setTogglingSslId(null)
}
}
async function confirmRemove() {
if (!removeTarget) return
setError('')
try {
await removeDomain(removeTarget.id)
setRemoveTarget(null)
showToast(`"${removeTarget.host}" has been removed.`, 'success')
setData((prev) => {
if (!prev) return prev
return {
...prev,
total: Math.max(0, prev.total - 1),
items: prev.items.filter((item) => item.id !== removeTarget.id),
}
})
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove domain.')
}
}
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Websites</h2>
<p className={pageStyles.pageSubtitle}>
Monitor and manage all domains across the platform.
</p>
</div>
</div>
<div className={tableStyles.filtersPanel}>
<div className={tableStyles.filtersTitle}>Filters</div>
<div className={tableStyles.filtersGrid}>
<div className={tableStyles.filtersInputs}>
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
<label htmlFor="filter-domain-name">Name</label>
<input
id="filter-domain-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="shop-a.local"
/>
</div>
</div>
<div className={tableStyles.filterActions}>
<button
type="button"
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={tableStyles.tablePanel}>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Domain list</div>
<div className={tableStyles.meta}>
{data ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
' '
)}
</div>
</div>
{error && (
<div style={{ padding: 16 }}>
<div className={tableStyles.meta} style={{ color: '#b91c1c' }}>
{error}
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={tableStyles.th}>Name</th>
<th className={tableStyles.th}>Owner business</th>
<th className={tableStyles.th}>Days to expire</th>
<th className={tableStyles.th}>SSL</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={tableStyles.td} colSpan={5}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={tableStyles.td} colSpan={5}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((domain) => (
<tr
key={domain.id}
className={!domain.isActive ? styles.inactiveRow : undefined}
>
<td className={tableStyles.td}>{domain.host}</td>
<td className={tableStyles.td}>{domain.businessName}</td>
<td className={tableStyles.td}>
<span className={daysLeftClass(domain.expiresAt)}>
{formatDaysLeft(domain.expiresAt)}
</span>
</td>
<td className={tableStyles.td}>
<div className={tableStyles.domainCell}>
{domain.sslEnabled ? (
<Lock size={16} className={tableStyles.sslOk} aria-label="SSL enabled" />
) : (
<AlertTriangle
size={16}
className={tableStyles.sslWarn}
aria-label="SSL not enabled"
/>
)}
<span className={tableStyles.subText}>
{domain.sslEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<span className={tableStyles.toggleInActions}>
<ToggleSwitch
checked={domain.isActive}
disabled={togglingActiveId === domain.id}
ariaLabel={`${domain.isActive ? 'Disable' : 'Enable'} ${domain.host}`}
onChange={(isActive) => void handleToggleActive(domain, isActive)}
/>
</span>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => void handleToggleSsl(domain, !domain.sslEnabled)}
disabled={togglingSslId === domain.id}
title={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'}
aria-label={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'}
>
{domain.sslEnabled ? <Lock size={16} /> : <Unlock size={16} />}
</button>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openEdit(domain)}
title="Edit"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
onClick={() => setRemoveTarget(domain)}
title="Remove"
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<Modal
open={editOpen}
title="Edit domain"
onClose={() => {
setEditOpen(false)
setEditDomain(null)
}}
>
<div className={tableStyles.field}>
<label htmlFor="edit-domain-host">Domain name</label>
<input
id="edit-domain-host"
value={editHost}
onChange={(e) => setEditHost(e.target.value)}
disabled={editSubmitting}
/>
</div>
<div style={{ height: 10 }} />
<div className={tableStyles.field}>
<label htmlFor="edit-domain-expires">Expiry date</label>
<input
id="edit-domain-expires"
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={editSubmitting}
/>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setEditOpen(false)}
disabled={editSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submitEdit()}
disabled={editSubmitting || !editHost.trim()}
>
Save
</button>
</div>
</Modal>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove domain?"
message={
removeTarget
? `Are you sure you want to remove "${removeTarget.host}"? This action cannot be undone.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
</main>
)
}
@@ -0,0 +1,20 @@
import { apiRequest, setTokens, clearTokens } from '../lib/api'
import type { LoginResponse, MeResponse } from '../types/auth'
export async function login(cellNumber: string, password: string) {
const data = await apiRequest<LoginResponse>('/auth/login', {
method: 'POST',
body: { cellNumber, password },
})
setTokens(data.accessToken, data.refreshToken)
return data
}
export async function fetchCurrentUser(signal?: AbortSignal) {
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
}
export function logout() {
clearTokens()
}
@@ -0,0 +1,93 @@
import { apiRequest } from '../lib/api'
import type { BusinessesListResponse, BusinessListItem, CreateBusinessPayload } from '../types/business'
export interface ListBusinessesParams {
page?: number
pageSize?: number
name?: string
domain?: string
category?: string
}
export interface UpdateBusinessPayload {
name?: string
slug?: string
}
export interface AddDomainPayload {
host: string
isPrimary?: boolean
}
export interface DisableBusinessPayload {
isActive: boolean
}
export interface UpdateDomainPayload {
host: string
}
export async function listBusinesses(params: ListBusinessesParams, signal?: AbortSignal) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.name) q.set('name', params.name)
if (params.domain) q.set('domain', params.domain)
if (params.category) q.set('category', params.category)
const query = q.toString()
const path = `/businesses${query ? `?${query}` : ''}`
return apiRequest<BusinessesListResponse>(path, { auth: true, signal })
}
export async function createBusiness(payload: CreateBusinessPayload) {
return apiRequest<BusinessListItem>('/businesses', {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateBusiness(businessId: string, payload: UpdateBusinessPayload) {
return apiRequest<BusinessListItem>(`/businesses/${businessId}`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function addBusinessDomain(businessId: string, payload: AddDomainPayload) {
return apiRequest(`/businesses/${businessId}/domains`, {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateBusinessDomain(
businessId: string,
domainId: number,
payload: UpdateDomainPayload,
) {
return apiRequest(`/businesses/${businessId}/domains/${domainId}`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function setBusinessActive(businessId: string, payload: DisableBusinessPayload) {
return apiRequest(`/businesses/${businessId}/disable`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function removeBusiness(businessId: string) {
return apiRequest(`/businesses/${businessId}`, {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,43 @@
import { apiRequest } from '../lib/api'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId
}
export interface BusinessSettings {
branding: BrandingSettings
dashboard: {
comments: { autoApprove: boolean }
expertReviews: { autoApprove: boolean }
}
store: {
onlineSellEnabled: boolean
orderProcessSteps: Array<{ id: string; label: string; color: string }>
}
}
export interface BusinessSettingsResponse {
businessId: string
settings: BusinessSettings
}
export async function getBusinessSettings(businessId: string, signal?: AbortSignal) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
auth: true,
signal,
})
}
export async function updateBusinessPrimaryColor(
businessId: string,
primaryColor: BusinessPrimaryColorId,
) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
method: 'PATCH',
auth: true,
body: {
branding: { primaryColor },
},
})
}
@@ -0,0 +1,6 @@
import { apiRequest } from '../lib/api'
import type { BusinessCategoriesResponse } from '../types/category'
export async function listBusinessCategories(signal?: AbortSignal) {
return apiRequest<BusinessCategoriesResponse>('/business-categories', { auth: true, signal })
}
@@ -0,0 +1,55 @@
import { apiRequest } from '../lib/api'
import type { DomainsListResponse } from '../types/domain'
export interface ListDomainsParams {
page?: number
pageSize?: number
name?: string
}
export interface UpdateDomainPayload {
host?: string
expiresAt?: string
}
export async function listDomains(params: ListDomainsParams, signal?: AbortSignal) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.name) q.set('name', params.name)
const query = q.toString()
const path = `/domains${query ? `?${query}` : ''}`
return apiRequest<DomainsListResponse>(path, { auth: true, signal })
}
export async function updateDomain(domainId: number | string, payload: UpdateDomainPayload) {
return apiRequest(`/domains/${domainId}`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function setDomainActive(domainId: number | string, isActive: boolean) {
return apiRequest(`/domains/${domainId}/disable`, {
method: 'PATCH',
auth: true,
body: { isActive },
})
}
export async function setDomainSsl(domainId: number | string, sslEnabled: boolean) {
return apiRequest(`/domains/${domainId}/ssl`, {
method: 'PATCH',
auth: true,
body: { sslEnabled },
})
}
export async function removeDomain(domainId: number | string) {
return apiRequest(`/domains/${domainId}`, {
method: 'DELETE',
auth: true,
})
}
@@ -0,0 +1,35 @@
import { apiRequest } from '../lib/api'
import type { AuthUser, MeResponse } from '../types/auth'
import type { ProfileFormData } from '../types/profile'
export async function updateProfile(data: ProfileFormData) {
return apiRequest<{ message: string; user: AuthUser }>('/auth/profile', {
method: 'PATCH',
auth: true,
body: {
firstName: data.firstName,
lastName: data.lastName,
email: data.email || null,
about: data.about,
city: data.city,
address: data.address,
landline: data.landline,
postalCode: data.postalCode,
instagram: data.instagram,
telegramId: data.telegramId,
linkedin: data.linkedin,
},
})
}
export async function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/change-password', {
method: 'POST',
auth: true,
body: { currentPassword, newPassword },
})
}
export async function fetchProfile() {
return apiRequest<MeResponse>('/auth/me', { auth: true })
}
@@ -0,0 +1,35 @@
import { apiRequest } from '../lib/api'
import type { RolesListResponse } from '../types/user'
export interface AddTeamMemberPayload {
cellNumber: string
firstName: string
lastName: string
roleSlug: string
email?: string
password?: string
}
export async function listTeamRoles(signal?: AbortSignal) {
return apiRequest<RolesListResponse>('/roles?scope=team', { auth: true, signal })
}
export async function addTeamMember(businessId: number | string, payload: AddTeamMemberPayload) {
return apiRequest(`/businesses/${businessId}/team`, {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateTeamMemberRole(
businessId: number | string,
memberId: number | string,
roleSlug: string,
) {
return apiRequest(`/businesses/${businessId}/team/${memberId}`, {
method: 'PATCH',
auth: true,
body: { roleSlug },
})
}
@@ -0,0 +1,102 @@
import { apiRequest } from '../lib/api'
import type { RolesListResponse, UserSearchResponse, UsersListResponse } from '../types/user'
export interface ListUsersParams {
page?: number
pageSize?: number
name?: string
cellNumber?: string
role?: string
businessId?: number
membership?: 'staff' | 'all'
}
export interface UpdateUserPayload {
firstName?: string
lastName?: string
email?: string
cellNumber?: string
}
export interface CreateUserPayload {
cellNumber: string
firstName: string
lastName: string
businessId: number
password?: string
email?: string
}
export async function searchUsers(q: string, limit = 20, signal?: AbortSignal) {
const params = new URLSearchParams({ q, limit: String(limit) })
return apiRequest<UserSearchResponse>(`/users/search?${params.toString()}`, {
auth: true,
signal,
})
}
export async function listUsers(params: ListUsersParams, signal?: AbortSignal) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.name) q.set('name', params.name)
if (params.cellNumber) q.set('cellNumber', params.cellNumber)
if (params.role) q.set('role', params.role)
if (params.businessId !== undefined) q.set('businessId', String(params.businessId))
if (params.membership) q.set('membership', params.membership)
const query = q.toString()
const path = `/users${query ? `?${query}` : ''}`
return apiRequest<UsersListResponse>(path, { auth: true, signal })
}
export async function listUserRoles(signal?: AbortSignal) {
return apiRequest<RolesListResponse>('/roles?scope=global', { auth: true, signal })
}
export async function updateUserRole(userId: number | string, roleSlug: string) {
return apiRequest<{ id: number; role: string; roles: string[] }>(`/users/${userId}/role`, {
method: 'PATCH',
auth: true,
body: { roleSlug },
})
}
export async function createUser(payload: CreateUserPayload) {
return apiRequest(`/users`, {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateUser(userId: number | string, payload: UpdateUserPayload) {
return apiRequest(`/users/${userId}`, {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function adminResetUserPassword(userId: number | string, newPassword: string) {
return apiRequest(`/users/${userId}/reset-password`, {
method: 'POST',
auth: true,
body: { newPassword },
})
}
export async function removeUser(userId: number | string) {
return apiRequest(`/users/${userId}`, {
method: 'DELETE',
auth: true,
})
}
export async function sendUserMessage(userId: number | string, message: string) {
return apiRequest<{ enabled: boolean; message: string }>(`/users/${userId}/send-message`, {
method: 'POST',
auth: true,
body: { message },
})
}
+51
View File
@@ -0,0 +1,51 @@
export type DashboardType = 'super_admin' | 'business' | 'customer'
export interface UserProfile {
about: string
city: string
address: string
landline: 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
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 RefreshResponse {
message: string
user: AuthUser
accessToken: string
refreshToken: string
}
+44
View File
@@ -0,0 +1,44 @@
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface BusinessOwnerInfo {
name: string | null
cellNumber: string | null
}
export interface BusinessDomainInfo {
host: string | null
sslEnabled: boolean | null
}
export interface BusinessListItem {
id: string
name: string
createdAt: string
domainId: number | null
domain: string | null
sslEnabled: boolean | null
ownerName: string | null
ownerCellNumber: string | null
isActive: boolean
primaryColor: BusinessPrimaryColorId
}
export interface CreateBusinessPayload {
name: string
nameFa: string
about?: string
slug?: string
categoryIds: number[]
ownerFirstName: string
ownerLastName: string
ownerCellNumber: string
ownerPassword: string
}
export interface BusinessesListResponse {
items: BusinessListItem[]
total: number
page: number
pageSize: number
}
+13
View File
@@ -0,0 +1,13 @@
export interface BusinessCategory {
id: number
parentId: number | null
name: string
slug: string
description: string | null
icon: string | null
sortOrder: number
}
export interface BusinessCategoriesResponse {
items: BusinessCategory[]
}
+17
View File
@@ -0,0 +1,17 @@
export interface DomainListItem {
id: number
host: string
businessId: number
businessName: string
sslEnabled: boolean
isActive: boolean
expiresAt: string | null
createdAt: string
}
export interface DomainsListResponse {
items: DomainListItem[]
total: number
page: number
pageSize: number
}
+36
View File
@@ -0,0 +1,36 @@
export interface UserProfile {
about: string
city: string
address: string
landline: string
postalCode: string
instagram: string
telegramId: string
linkedin: string
}
export interface ProfileFormData {
firstName: string
lastName: string
cellNumber: string
email: string
about: string
city: string
address: string
landline: string
postalCode: string
instagram: string
telegramId: string
linkedin: string
}
export const emptyProfile: UserProfile = {
about: '',
city: '',
address: '',
landline: '',
postalCode: '',
instagram: '',
telegramId: '',
linkedin: '',
}
+43
View File
@@ -0,0 +1,43 @@
export interface UserSearchItem {
id: number
cellNumber: string
firstName: string | null
lastName: string | null
email: string | null
label: string
}
export interface UserSearchResponse {
items: UserSearchItem[]
}
export interface UserListItem {
id: number
firstName: string | null
lastName: string | null
cellNumber: string
roles: string | null
roleSlug: string | null
businesses: string | null
businessMemberId: number | null
isBusinessOwner: boolean | null
teamRole: string | null
createdAt: string
isActive: boolean
}
export interface UsersListResponse {
items: UserListItem[]
total: number
page: number
pageSize: number
}
export interface RoleOption {
slug: string
name: string
}
export interface RolesListResponse {
items: RoleOption[]
}
@@ -0,0 +1,95 @@
export const BUSINESS_PRIMARY_COLOR_IDS = [
'red',
'yellow',
'black',
'cyan',
'purple',
'light-blue',
'dark-blue',
] as const
export type BusinessPrimaryColorId = (typeof BUSINESS_PRIMARY_COLOR_IDS)[number]
export const DEFAULT_BUSINESS_PRIMARY_COLOR_ID: BusinessPrimaryColorId = 'dark-blue'
export type BusinessPrimaryColorTokens = {
label: string
primary: string
primaryDark: string
primaryLight: string
primaryGlow: string
primaryRgb: string
}
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId,
BusinessPrimaryColorTokens
> = {
red: {
label: 'Red',
primary: '#ef4444',
primaryDark: '#dc2626',
primaryLight: '#fee2e2',
primaryGlow: '#ef4444',
primaryRgb: '239 68 68',
},
yellow: {
label: 'Yellow',
primary: '#eab308',
primaryDark: '#ca8a04',
primaryLight: '#fef9c3',
primaryGlow: '#eab308',
primaryRgb: '234 179 8',
},
black: {
label: 'Black',
primary: '#1e293b',
primaryDark: '#0f172a',
primaryLight: '#e2e8f0',
primaryGlow: '#334155',
primaryRgb: '30 41 59',
},
cyan: {
label: 'Cyan',
primary: '#06b6d4',
primaryDark: '#0891b2',
primaryLight: '#cffafe',
primaryGlow: '#06b6d4',
primaryRgb: '6 182 212',
},
purple: {
label: 'Purple',
primary: '#a855f7',
primaryDark: '#9333ea',
primaryLight: '#f3e8ff',
primaryGlow: '#a855f7',
primaryRgb: '168 85 247',
},
'light-blue': {
label: 'Light Blue',
primary: '#38bdf8',
primaryDark: '#0ea5e9',
primaryLight: '#e0f2fe',
primaryGlow: '#38bdf8',
primaryRgb: '56 189 248',
},
'dark-blue': {
label: 'Dark Blue',
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryLight: '#dbeafe',
primaryGlow: '#3b82f6',
primaryRgb: '59 130 246',
},
}
export function normalizeBusinessPrimaryColorId(value: unknown): BusinessPrimaryColorId {
if (
typeof value === 'string' &&
BUSINESS_PRIMARY_COLOR_IDS.includes(value as BusinessPrimaryColorId)
) {
return value as BusinessPrimaryColorId
}
return DEFAULT_BUSINESS_PRIMARY_COLOR_ID
}
+22
View File
@@ -0,0 +1,22 @@
import type { BusinessCategory } from '../types/category'
import type { MultiSelectOption } from '../components/MultiSelectDropdown'
export function flattenBusinessCategories(
categories: BusinessCategory[],
): MultiSelectOption<number>[] {
const result: MultiSelectOption<number>[] = []
function walk(parentId: number | null, depth: number) {
const children = categories
.filter((category) => category.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
for (const child of children) {
result.push({ value: child.id, label: child.name, depth })
walk(child.id, depth + 1)
}
}
walk(null, 0)
return result
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_ADMIN_DOMAIN?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const ADMIN_DOMAIN = process.env.VITE_ADMIN_DOMAIN ?? 'meshkee.app'
const certDir = path.resolve(__dirname, '.certs')
const certFile = path.join(certDir, `${ADMIN_DOMAIN}.pem`)
const keyFile = path.join(certDir, `${ADMIN_DOMAIN}-key.pem`)
function getHttpsConfig() {
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
console.warn(
`[vite] Missing mkcert files in .certs/. Run:\n` +
` mkcert -install\n` +
` mkcert -cert-file .certs/${ADMIN_DOMAIN}.pem -key-file .certs/${ADMIN_DOMAIN}-key.pem ${ADMIN_DOMAIN}`,
)
return undefined
}
return {
key: fs.readFileSync(keyFile),
cert: fs.readFileSync(certFile),
}
}
const https = getHttpsConfig()
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom'],
},
server: {
host: true,
port: 5174,
https,
fs: {
allow: ['..', '../..'],
},
allowedHosts: [ADMIN_DOMAIN, 'localhost', '127.0.0.1'],
},
preview: {
host: true,
port: 5174,
https,
allowedHosts: [ADMIN_DOMAIN, 'localhost', '127.0.0.1'],
},
})