Add business dashboard dark mode from Super Admin theme setting.

Login and in-app surfaces follow tenant themeMode, matching customer dark tokens, with themed switches and tighter card/table chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 13:40:40 +03:30
co-authored by Cursor
parent 65012717d0
commit e9400c2281
83 changed files with 586 additions and 193 deletions
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react'
import { lockBodyScroll, unlockBodyScroll } from '../utils/lockBodyScroll'
import styles from './ConfirmDeleteModal.module.css'
interface ConfirmDeleteModalProps {
@@ -22,6 +23,8 @@ export function ConfirmDeleteModal({
}: ConfirmDeleteModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const onCancelRef = useRef(onCancel)
onCancelRef.current = onCancel
useEffect(() => {
if (open) {
@@ -40,16 +43,15 @@ export function ConfirmDeleteModal({
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
if (e.key === 'Escape') onCancelRef.current()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
lockBodyScroll()
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
unlockBodyScroll()
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onCancel])
}, [mounted, closing])
if (!mounted) return null
+9 -6
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { lockBodyScroll, unlockBodyScroll } from '../utils/lockBodyScroll'
import styles from './Modal.module.css'
interface ModalProps {
@@ -14,19 +15,21 @@ interface ModalProps {
}
export function Modal({ open, title, children, onClose, wide, xl }: ModalProps) {
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape') onCloseRef.current()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
lockBodyScroll()
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
unlockBodyScroll()
document.removeEventListener('keydown', onKey)
}
}, [open, onClose])
}, [open])
if (!open) return null
@@ -4,12 +4,42 @@
height: 22px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.45);
overflow: hidden;
transition: background 0.2s;
flex-shrink: 0;
}
.switch::before,
.switch::after {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-en), var(--font-ui);
font-size: 9px;
font-weight: 700;
line-height: 1;
pointer-events: none;
z-index: 0;
color: var(--text-primary);
}
.switch::before {
content: '1';
left: 6px;
}
.switch::after {
content: '0';
right: 6px;
}
.switch.on {
background: #16a34a;
background: var(--primary);
}
.switch.on::before,
.switch.on::after {
color: #fff;
}
.thumb {
@@ -19,9 +49,10 @@
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform 0.2s;
z-index: 1;
}
.switch.on .thumb {
@@ -576,6 +576,44 @@
line-height: 1.3;
}
.renewalDaysRow {
display: inline-flex;
align-items: center;
gap: 4px;
}
.renewalRefreshBtn {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 5px;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.renewalRefreshBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.renewalRefreshBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.renewalRefreshSpin {
animation: renewalRefreshSpin 0.8s linear infinite;
}
@keyframes renewalRefreshSpin {
to {
transform: rotate(360deg);
}
}
.renewalOk {
color: #15803d;
}
+104 -33
View File
@@ -9,6 +9,7 @@ import {
Lock,
Pencil,
Plus,
RefreshCw,
RotateCcw,
Search,
Trash2,
@@ -112,28 +113,43 @@ function emptyModuleSelection(
) as Record<BusinessModuleId, boolean>
}
/** Next anniversary of the contract date (renews every year). */
function startOfLocalDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/** Anniversary of the contract date. Does not roll a missed date into next year. */
function nextAnnualRenewal(contractAt: string, now = new Date()) {
const contract = new Date(contractAt)
if (Number.isNaN(contract.getTime())) return null
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
let next = new Date(
startToday.getFullYear(),
contract.getMonth(),
contract.getDate(),
)
if (next.getTime() < startToday.getTime()) {
next = new Date(
startToday.getFullYear() + 1,
contract.getMonth(),
contract.getDate(),
)
}
const startToday = startOfLocalDay(now)
const contractDay = startOfLocalDay(contract)
const due =
contractDay.getTime() > startToday.getTime()
? contractDay
: new Date(
startToday.getFullYear(),
contract.getMonth(),
contract.getDate(),
)
const daysRemaining = Math.round(
(next.getTime() - startToday.getTime()) / (24 * 60 * 60 * 1000),
(due.getTime() - startToday.getTime()) / (24 * 60 * 60 * 1000),
)
return { next, year: next.getFullYear(), daysRemaining }
return { next: due, year: due.getFullYear(), daysRemaining }
}
function oneYearFromTodayIso(from = new Date()) {
const next = new Date(
from.getFullYear() + 1,
from.getMonth(),
from.getDate(),
12,
0,
0,
)
return next.toISOString()
}
function toDateInputValue(iso: string | null | undefined): string {
@@ -165,12 +181,18 @@ function formatRenewalCell(
if (!next) {
return { dateLabel: '—', daysLabel: null, tone: null }
}
const overdue = next.daysRemaining < 0
const absDays = Math.abs(next.daysRemaining)
const daysLabel =
next.daysRemaining === 0
? 'Renews today'
: next.daysRemaining === 1
? '1 day left'
: `${next.daysRemaining} days left`
: overdue
? absDays === 1
? '1 day past'
: `${absDays} days past`
: next.daysRemaining === 1
? '1 day left'
: `${next.daysRemaining} days left`
return {
dateLabel: next.next.toLocaleDateString('en-US', {
month: 'short',
@@ -178,7 +200,7 @@ function formatRenewalCell(
year: 'numeric',
}),
daysLabel,
tone: renewalTone(next.daysRemaining),
tone: overdue ? ('danger' as const) : renewalTone(next.daysRemaining),
}
}
@@ -360,6 +382,7 @@ export function BusinessesPage() {
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [savingRenewalId, setSavingRenewalId] = useState<string | null>(null)
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
const [modulesOpen, setModulesOpen] = useState(false)
@@ -861,14 +884,42 @@ export function BusinessesPage() {
setEditOpen(false)
setEditBusiness(null)
showToast('Business updated.', 'success')
await fetchList()
} catch (err) {
setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
setEditError(
err instanceof ApiError ? err.message : 'Unable to update business.',
)
} finally {
setEditSubmitting(false)
}
}
async function restartRenewalCountdown(b: BusinessListItem) {
const annualRenewalAt = oneYearFromTodayIso()
setSavingRenewalId(b.id)
try {
await updateBusiness(b.id, { annualRenewalAt })
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, annualRenewalAt } : item,
),
}
})
showToast(`Renewal countdown restarted for "${b.name}".`, 'success')
} catch (err) {
showToast(
err instanceof ApiError
? err.message
: 'Unable to restart renewal countdown.',
'error',
)
} finally {
setSavingRenewalId(null)
}
}
function openDomainEdit(b: BusinessListItem) {
setDomainBusiness(b)
setDomainId(b.domainId)
@@ -1457,17 +1508,37 @@ export function BusinessesPage() {
<div className={styles.renewalCell}>
<span className={styles.renewalDate}>{renewal.dateLabel}</span>
{renewal.daysLabel ? (
<span
className={`${styles.renewalDays} ${
renewal.tone === 'ok'
? styles.renewalOk
: renewal.tone === 'warn'
? styles.renewalWarn
: styles.renewalDanger
}`}
>
{renewal.daysLabel}
</span>
<div className={styles.renewalDaysRow}>
<span
className={`${styles.renewalDays} ${
renewal.tone === 'ok'
? styles.renewalOk
: renewal.tone === 'warn'
? styles.renewalWarn
: styles.renewalDanger
}`}
>
{renewal.daysLabel}
</span>
<button
type="button"
className={styles.renewalRefreshBtn}
onClick={() => void restartRenewalCountdown(b)}
disabled={savingRenewalId === b.id}
aria-label={`Restart renewal countdown for ${b.name}`}
title="Restart countdown"
>
<RefreshCw
size={12}
aria-hidden="true"
className={
savingRenewalId === b.id
? styles.renewalRefreshSpin
: undefined
}
/>
</button>
</div>
) : null}
</div>
</td>
@@ -0,0 +1,19 @@
/** Nested-modal-safe body scroll lock. Restoring a captured overflow value
* leaves `overflow: hidden` on body after overlapping dialogs close. */
let lockCount = 0
export function lockBodyScroll() {
lockCount += 1
if (lockCount === 1) {
document.documentElement.style.overflow = 'hidden'
document.body.style.overflow = 'hidden'
}
}
export function unlockBodyScroll() {
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0) {
document.documentElement.style.overflow = ''
document.body.style.overflow = ''
}
}