mirror of
				https://github.com/etiennecollin/unifi-voucher-manager.git
				synced 2025-11-03 21:33:17 +00:00 
			
		
		
		
	refactor: voucher cards and voucher status
This commit is contained in:
		@@ -1,5 +1,10 @@
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
@@ -12,7 +17,9 @@ type Props = {
 | 
			
		||||
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
 | 
			
		||||
  const statusClass = voucher.expired
 | 
			
		||||
    ? "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(
 | 
			
		||||
    () => onClick?.(voucher),
 | 
			
		||||
    [voucher, onClick],
 | 
			
		||||
@@ -69,7 +76,7 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
 | 
			
		||||
          <span
 | 
			
		||||
            className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`}
 | 
			
		||||
          >
 | 
			
		||||
            {voucher.expired ? "Expired" : "Active"}
 | 
			
		||||
            {formatStatus(voucher.expired, voucher.activatedAt)}
 | 
			
		||||
          </span>
 | 
			
		||||
          {voucher.expiresAt && (
 | 
			
		||||
            <span className="text-xs">Expires: {voucher.expiresAt}</span>
 | 
			
		||||
 
 | 
			
		||||
@@ -3,15 +3,17 @@
 | 
			
		||||
import Modal from "@/components/modals/Modal";
 | 
			
		||||
import Spinner from "@/components/utils/Spinner";
 | 
			
		||||
import { api } from "@/utils/api";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from "react";
 | 
			
		||||
import {
 | 
			
		||||
  formatBytes,
 | 
			
		||||
  formatDuration,
 | 
			
		||||
  formatGuestUsage,
 | 
			
		||||
  formatSpeed,
 | 
			
		||||
  formatStatus,
 | 
			
		||||
} from "@/utils/format";
 | 
			
		||||
import VoucherCode from "@/components/utils/VoucherCode";
 | 
			
		||||
import { Voucher } from "@/types/voucher";
 | 
			
		||||
import { TriState } from "@/types/state";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  voucher: Voucher;
 | 
			
		||||
@@ -20,8 +22,7 @@ type Props = {
 | 
			
		||||
 | 
			
		||||
export default function VoucherModal({ voucher, onClose }: Props) {
 | 
			
		||||
  const [details, setDetails] = useState<Voucher | null>(null);
 | 
			
		||||
  const [loading, setLoading] = useState(true);
 | 
			
		||||
  const [error, setError] = useState(false);
 | 
			
		||||
  const [state, setState] = useState<TriState | null>(null);
 | 
			
		||||
  const lastFetchedId = useRef<string | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@@ -31,70 +32,81 @@ export default function VoucherModal({ voucher, onClose }: Props) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    (async () => {
 | 
			
		||||
      setLoading(true);
 | 
			
		||||
      setError(false);
 | 
			
		||||
      setState("loading");
 | 
			
		||||
      lastFetchedId.current = voucher.id;
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        const res = await api.getVoucherDetails(voucher.id);
 | 
			
		||||
        setDetails(res);
 | 
			
		||||
        setState("ok");
 | 
			
		||||
      } catch {
 | 
			
		||||
        setError(true);
 | 
			
		||||
      } finally {
 | 
			
		||||
        setLoading(false);
 | 
			
		||||
        setState("error");
 | 
			
		||||
      }
 | 
			
		||||
    })();
 | 
			
		||||
  }, [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 (
 | 
			
		||||
    <Modal onClose={onClose}>
 | 
			
		||||
      <VoucherCode voucher={voucher} contentClassName="mb-8" />
 | 
			
		||||
      {loading ? (
 | 
			
		||||
        <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>
 | 
			
		||||
      )}
 | 
			
		||||
      {renderContent()}
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,15 @@ export function formatMaxGuests(maxGuests: number | null | undefined) {
 | 
			
		||||
  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) {
 | 
			
		||||
  if (!m) return "Unlimited";
 | 
			
		||||
  const days = Math.floor(m / 1440),
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user