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
@@ -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>
)
}