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:
co-authored by
Cursor
parent
65012717d0
commit
e9400c2281
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user