refactor: voucher cards and voucher status

This commit is contained in:
etiennecollin
2025-09-07 15:54:24 -04:00
parent b81efc3c87
commit dacd6a9a5c
3 changed files with 85 additions and 57 deletions

View File

@@ -1,5 +1,10 @@
import { Voucher } from "@/types/voucher"; import { Voucher } from "@/types/voucher";
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format"; import {
formatCode,
formatDuration,
formatGuestUsage,
formatStatus,
} from "@/utils/format";
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
type Props = { type Props = {
@@ -12,7 +17,9 @@ type Props = {
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => { const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
const statusClass = voucher.expired const statusClass = voucher.expired
? "bg-status-danger text-status-danger" ? "bg-status-danger text-status-danger"
: "bg-status-success text-status-success"; : voucher.activatedAt
? "bg-status-warning text-status-warning"
: "bg-status-success text-status-success";
const onClickHandler = useCallback( const onClickHandler = useCallback(
() => onClick?.(voucher), () => onClick?.(voucher),
[voucher, onClick], [voucher, onClick],
@@ -69,7 +76,7 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
<span <span
className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`} className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`}
> >
{voucher.expired ? "Expired" : "Active"} {formatStatus(voucher.expired, voucher.activatedAt)}
</span> </span>
{voucher.expiresAt && ( {voucher.expiresAt && (
<span className="text-xs">Expires: {voucher.expiresAt}</span> <span className="text-xs">Expires: {voucher.expiresAt}</span>

View File

@@ -3,15 +3,17 @@
import Modal from "@/components/modals/Modal"; import Modal from "@/components/modals/Modal";
import Spinner from "@/components/utils/Spinner"; import Spinner from "@/components/utils/Spinner";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
formatBytes, formatBytes,
formatDuration, formatDuration,
formatGuestUsage, formatGuestUsage,
formatSpeed, formatSpeed,
formatStatus,
} from "@/utils/format"; } from "@/utils/format";
import VoucherCode from "@/components/utils/VoucherCode"; import VoucherCode from "@/components/utils/VoucherCode";
import { Voucher } from "@/types/voucher"; import { Voucher } from "@/types/voucher";
import { TriState } from "@/types/state";
type Props = { type Props = {
voucher: Voucher; voucher: Voucher;
@@ -20,8 +22,7 @@ type Props = {
export default function VoucherModal({ voucher, onClose }: Props) { export default function VoucherModal({ voucher, onClose }: Props) {
const [details, setDetails] = useState<Voucher | null>(null); const [details, setDetails] = useState<Voucher | null>(null);
const [loading, setLoading] = useState(true); const [state, setState] = useState<TriState | null>(null);
const [error, setError] = useState(false);
const lastFetchedId = useRef<string | null>(null); const lastFetchedId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -31,70 +32,81 @@ export default function VoucherModal({ voucher, onClose }: Props) {
} }
(async () => { (async () => {
setLoading(true); setState("loading");
setError(false);
lastFetchedId.current = voucher.id; lastFetchedId.current = voucher.id;
try { try {
const res = await api.getVoucherDetails(voucher.id); const res = await api.getVoucherDetails(voucher.id);
setDetails(res); setDetails(res);
setState("ok");
} catch { } catch {
setError(true); setState("error");
} finally {
setLoading(false);
} }
})(); })();
}, [voucher.id]); }, [voucher.id]);
const renderContent = useCallback(() => {
switch (state) {
case null:
case "loading":
return <Spinner />;
case "error":
return (
<div className="card text-status-danger text-center">
Failed to load detailed information
</div>
);
case "ok":
if (details == null) {
return;
}
return (
<div className="space-y-4">
{(
[
["Status", formatStatus(details.expired, details.activatedAt)],
["Name", details.name || "No note"],
["Created", details.createdAt],
...(details.activatedAt
? [["Activated", details.activatedAt]]
: []),
...(details.expiresAt ? [["Expires", details.expiresAt]] : []),
["Duration", formatDuration(details.timeLimitMinutes)],
[
"Guest Usage",
formatGuestUsage(
details.authorizedGuestCount,
details.authorizedGuestLimit,
),
],
[
"Data Limit",
details.dataUsageLimitMBytes
? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024)
: "Unlimited",
],
["Download Speed", formatSpeed(details.rxRateLimitKbps)],
["Upload Speed", formatSpeed(details.txRateLimitKbps)],
["ID", details.id],
] as [string, any][]
).map(([label, value]) => (
<div
key={label}
className="flex-center-between p-4 bg-interactive border border-subtle rounded-xl space-x-4"
>
<span className="font-semibold text-primary">{label}:</span>
<span className="text-secondary">{value}</span>
</div>
))}
</div>
);
}
}, [state, details]);
return ( return (
<Modal onClose={onClose}> <Modal onClose={onClose}>
<VoucherCode voucher={voucher} contentClassName="mb-8" /> <VoucherCode voucher={voucher} contentClassName="mb-8" />
{loading ? ( {renderContent()}
<Spinner />
) : error || details == null ? (
<div className="card text-status-danger text-center">
Failed to load detailed information
</div>
) : (
<div className="space-y-4">
{(
[
["Status", details.expired ? "Expired" : "Active"],
["Name", details.name || "No note"],
["Created", details.createdAt],
...(details.activatedAt
? [["Activated", details.activatedAt]]
: []),
...(details.expiresAt ? [["Expires", details.expiresAt]] : []),
["Duration", formatDuration(details.timeLimitMinutes)],
[
"Guest Usage",
formatGuestUsage(
details.authorizedGuestCount,
details.authorizedGuestLimit,
),
],
[
"Data Limit",
details.dataUsageLimitMBytes
? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024)
: "Unlimited",
],
["Download Speed", formatSpeed(details.rxRateLimitKbps)],
["Upload Speed", formatSpeed(details.txRateLimitKbps)],
["ID", details.id],
] as [string, any][]
).map(([label, value]) => (
<div
key={label}
className="flex-center-between p-4 bg-interactive border border-subtle rounded-xl space-x-4"
>
<span className="font-semibold text-primary">{label}:</span>
<span className="text-secondary">{value}</span>
</div>
))}
</div>
)}
</Modal> </Modal>
); );
} }

View File

@@ -6,6 +6,15 @@ export function formatMaxGuests(maxGuests: number | null | undefined) {
return !maxGuests ? "Unlimited" : Math.max(maxGuests, 0); return !maxGuests ? "Unlimited" : Math.max(maxGuests, 0);
} }
export function formatStatus(
expired: boolean,
activatedAt: string | null | undefined,
) {
if (expired) return "Expired";
if (activatedAt) return "Active";
return "Available";
}
export function formatDuration(m: number | null | undefined) { export function formatDuration(m: number | null | undefined) {
if (!m) return "Unlimited"; if (!m) return "Unlimited";
const days = Math.floor(m / 1440), const days = Math.floor(m / 1440),