mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
115 lines
3.7 KiB
TypeScript
115 lines
3.7 KiB
TypeScript
import { Group, Loader, Text } from "@mantine/core";
|
|
import { Clock } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
export interface CountdownTimerProps {
|
|
/** ISO timestamp the countdown targets. */
|
|
deadline: string | null | undefined;
|
|
/** Optional label shown before the time (e.g. "Window closes in"). */
|
|
label?: string;
|
|
/** Text shown once the deadline has passed. */
|
|
expiredText?: string;
|
|
/**
|
|
* Optional grace stage: once `deadline` passes, count down to this later
|
|
* ISO timestamp instead (e.g. the payment drain tail). `expiredText` then
|
|
* only shows after the grace deadline has also passed.
|
|
*/
|
|
graceDeadline?: string | null;
|
|
/** Label shown while counting down the grace stage (e.g. "Payment processing — closes in"). */
|
|
graceLabel?: string;
|
|
/** Visual size of the time text. */
|
|
size?: "xs" | "sm" | "md" | "lg";
|
|
/** Colour once under this many seconds remain (urgency). Default 300 (5 min). */
|
|
urgentUnderSeconds?: number;
|
|
}
|
|
|
|
function pad(n: number): string {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
/** Break a remaining-milliseconds figure into a human string. */
|
|
function formatRemaining(ms: number): string {
|
|
const total = Math.floor(ms / 1000);
|
|
const days = Math.floor(total / 86400);
|
|
const hours = Math.floor((total % 86400) / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
const seconds = total % 60;
|
|
|
|
if (days > 0) return `${days}d ${pad(hours)}h ${pad(minutes)}m`;
|
|
if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`;
|
|
return `${pad(minutes)}m ${pad(seconds)}s`;
|
|
}
|
|
|
|
/**
|
|
* Live countdown to an ISO deadline. Ticks once a second, shows the remaining
|
|
* time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows
|
|
* `expiredText` once the deadline is in the past. When `graceDeadline` is set,
|
|
* a lapsed main deadline rolls into a calm second-stage countdown (spinner,
|
|
* no urgency colours) toward it before `expiredText` takes over. Display only
|
|
* — enforcement lives server-side.
|
|
*/
|
|
export function CountdownTimer({
|
|
deadline,
|
|
label,
|
|
expiredText = "Expired",
|
|
graceDeadline,
|
|
graceLabel,
|
|
size = "sm",
|
|
urgentUnderSeconds = 300,
|
|
}: CountdownTimerProps) {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
|
|
useEffect(() => {
|
|
if (!deadline) return;
|
|
setNow(Date.now());
|
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(id);
|
|
}, [deadline, graceDeadline]);
|
|
|
|
if (!deadline) return null;
|
|
const target = new Date(deadline).getTime();
|
|
if (Number.isNaN(target)) return null;
|
|
|
|
const remaining = target - now;
|
|
const expired = remaining <= 0;
|
|
|
|
const graceTarget = graceDeadline ? new Date(graceDeadline).getTime() : NaN;
|
|
const graceRemaining = Number.isNaN(graceTarget) ? 0 : graceTarget - now;
|
|
const inGrace = expired && graceRemaining > 0;
|
|
|
|
if (inGrace) {
|
|
return (
|
|
<Group gap={6} align="center" wrap="nowrap">
|
|
<Loader size={size === "lg" ? 18 : 14} color="blue.7" />
|
|
{graceLabel && (
|
|
<Text size={size} c="dimmed">
|
|
{graceLabel}
|
|
</Text>
|
|
)}
|
|
<Text size={size} fw={600} c="blue.7">
|
|
{formatRemaining(graceRemaining)}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const urgent = !expired && remaining <= urgentUnderSeconds * 1000;
|
|
const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed";
|
|
|
|
return (
|
|
<Group gap={6} align="center" wrap="nowrap">
|
|
<Clock size={size === "lg" ? 18 : 14} />
|
|
{label && (
|
|
<Text size={size} c="dimmed">
|
|
{label}
|
|
</Text>
|
|
)}
|
|
<Text size={size} fw={600} c={color}>
|
|
{expired ? expiredText : formatRemaining(remaining)}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export default CountdownTimer;
|