feat: bulk printing of vouchers

This commit is contained in:
etiennecollin
2025-08-19 02:48:33 -04:00
parent 8f8fc49ba8
commit c74b682b00
12 changed files with 431 additions and 274 deletions

View File

@@ -0,0 +1,176 @@
"use client";
import "./styles.css";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { Voucher } from "@/types/voucher";
import {
formatBytes,
formatDuration,
formatMaxGuests,
formatSpeed,
} from "@/utils/format";
import { useGlobal } from "@/contexts/GlobalContext";
import { formatCode } from "@/utils/format";
export type PrintMode = "list" | "grid";
function VoucherBlock({ voucher }: { voucher: Voucher }) {
const { wifiConfig, wifiString } = useGlobal();
return (
<div className="print-voucher">
<div className="print-header">
<div className="print-title">WiFi Access Voucher</div>
</div>
<div className="print-voucher-code">{formatCode(voucher.code)}</div>
<div className="print-info-row">
<span className="print-label">Duration:</span>
<span className="print-value">
{formatDuration(voucher.timeLimitMinutes)}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Max Guests:</span>
<span className="print-value">
{formatMaxGuests(voucher.authorizedGuestLimit)}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Data Limit:</span>
<span className="print-value">
{voucher.dataUsageLimitMBytes
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
: "Unlimited"}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Down Speed:</span>
<span className="print-value">
{formatSpeed(voucher.rxRateLimitKbps)}
</span>
</div>
<div className="print-info-row">
<span className="print-label">Up Speed:</span>
<span className="print-value">
{formatSpeed(voucher.txRateLimitKbps)}
</span>
</div>
{wifiConfig && (
<div className="print-qr-section">
{wifiString && (
<>
<div className="font-bold mb-2">Scan to Connect</div>
<QRCodeSVG
value={wifiString}
size={140}
level="H"
marginSize={4}
title="Wi-Fi Access QR Code"
/>
</>
)}
<div className="print-qr-text">
<strong>Network:</strong> {wifiConfig.ssid}
<br />
{wifiConfig.type === "nopass" ? (
"No Password"
) : (
<>
<strong>Password:</strong> {wifiConfig.password}
</>
)}
{wifiConfig.hidden && <div>(Hidden Network)</div>}
</div>
</div>
)}
<div className="print-footer">
<div>
<strong className="text-sm">ID:</strong> {voucher.id}
</div>
<div>
<strong className="text-sm">Printed:</strong>{" "}
{new Date().toUTCString()}
</div>
</div>
</div>
);
}
function Vouchers() {
const router = useRouter();
const searchParams = useSearchParams();
const [vouchers, setVouchers] = useState<Voucher[]>([]);
const [mode, setMode] = useState<PrintMode>("list");
const lastSearchParams = useRef<string | null>(null);
useEffect(() => {
const paramString = searchParams.toString();
if (lastSearchParams.current === paramString) {
return;
}
lastSearchParams.current = paramString;
const vouchersParam = searchParams.get("vouchers");
const modeParam = searchParams.get("mode");
if (!vouchersParam || !modeParam) {
return;
}
try {
const parsedVouchers = JSON.parse(decodeURIComponent(vouchersParam));
setVouchers(parsedVouchers);
setMode(modeParam as PrintMode);
setTimeout(() => {
window.print();
router.replace("/");
}, 100);
} catch (error) {
console.error("Failed to parse vouchers:", error);
}
}, [searchParams, router]);
return !vouchers.length ? (
<div style={{ textAlign: "center" }}>
No vouchers to print, press backspace
</div>
) : (
<div className={mode === "grid" ? "print-grid" : "print-list"}>
{vouchers.map((v) => (
<VoucherBlock key={v.id} voucher={v} />
))}
</div>
);
}
export default function PrintPage() {
const router = useRouter();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Backspace") router.replace("/");
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
};
}, [router]);
return (
<div className="print-wrapper">
<Suspense
fallback={<div style={{ textAlign: "center" }}>Loading...</div>}
>
<Vouchers />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,67 @@
@import "tailwindcss";
/* Print page settings */
@media print {
@page {
size: auto;
margin: 4; /* ~3mm is too fine; browsers usually round to ~4px */
}
html,
body {
overflow: visible !important;
height: auto !important;
width: auto !important;
position: static !important;
touch-action: auto !important;
background: white !important;
color: black !important;
}
}
.print-wrapper {
@apply p-3 m-0 p-0 bg-white text-black font-mono text-sm leading-snug;
}
.print-list {
@apply flex flex-col gap-3 max-w-[80mm] mx-auto;
}
.print-grid {
@apply grid gap-3 grid-cols-[repeat(auto-fill,minmax(250px,1fr))];
}
.print-voucher {
@apply border-2 border-black p-3;
}
.print-header {
@apply text-center border-b-2 border-black pb-1;
}
.print-title {
@apply font-bold text-lg;
}
.print-voucher-code {
@apply text-xl font-bold text-center border-2 border-black p-1 my-3;
}
.print-info-row {
@apply flex justify-between my-1.5 border-b border-dotted border-black;
}
.print-label {
@apply font-bold flex-1;
}
.print-value {
@apply flex-1 text-right;
}
.print-qr-section {
@apply flex flex-col items-center justify-center my-3 border-t-2 border-black pt-2;
}
.print-qr-section .print-qr-text {
@apply text-center text-xs mt-2;
}
/* Footer */
.print-footer {
@apply text-left border-t-2 border-black pt-2 text-xs;
}

View File

@@ -1,22 +1,26 @@
import { Voucher } from "@/types/voucher";
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
import { memo } from "react";
import { memo, useCallback } from "react";
type Props = {
voucher: Voucher;
selected: boolean;
editMode: boolean;
onClick?: () => void;
onClick?: (v: Voucher) => void;
};
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
const statusClass = voucher.expired
? "bg-status-danger text-status-danger"
: "bg-status-success text-status-success";
const onClickHandler = useCallback(
() => onClick?.(voucher),
[voucher, onClick],
);
return (
<div
onClick={onClick}
onClick={onClickHandler}
className={`card card-interactive
${selected ? "border-accent" : ""}
${editMode ? "relative" : ""}`}

View File

@@ -16,7 +16,6 @@ export default function Modal({
ref,
children,
}: Props) {
// lock scroll + handle Escape
useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";

View File

@@ -46,8 +46,6 @@ export default function VoucherModal({ voucher, onClose }: Props) {
})();
}, [voucher.id]);
const rawCode = details?.code ?? voucher.code;
return (
<Modal onClose={onClose}>
<VoucherCode voucher={voucher} contentClassName="mb-8" />

View File

@@ -3,10 +3,12 @@
import Spinner from "@/components/utils/Spinner";
import VoucherCard from "@/components/VoucherCard";
import VoucherModal from "@/components/modals/VoucherModal";
import { PrintMode } from "@/app/print/page";
import { Voucher } from "@/types/voucher";
import { api } from "@/utils/api";
import { notify } from "@/utils/notifications";
import { useMemo, useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
export default function VouchersTab() {
const [loading, setLoading] = useState(true);
@@ -14,8 +16,28 @@ export default function VouchersTab() {
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const router = useRouter();
const filteredVouchers = useMemo(() => {
if (!searchQuery.trim()) return vouchers;
const query = searchQuery.toLowerCase().trim();
return vouchers.filter((voucher) =>
voucher.name?.toLowerCase().includes(query),
);
}, [vouchers, searchQuery]);
const expiredIds = useMemo(
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
[filteredVouchers],
);
const selectedVouchers = useMemo(
() => filteredVouchers.filter((v) => selectedIds.has(v.id)),
[filteredVouchers, selectedIds],
);
const load = useCallback(async () => {
setLoading(true);
@@ -28,16 +50,71 @@ export default function VouchersTab() {
setLoading(false);
}, []);
const startEdit = () => {
setSelected(new Set());
const startEdit = useCallback(() => {
setSelectedIds(new Set());
setEditMode(true);
};
}, []);
const cancelEdit = useCallback(() => {
setSelected(new Set());
setSelectedIds(new Set());
setEditMode(false);
}, []);
const toggleSelect = useCallback((id: string) => {
setSelectedIds((p) => {
const s = new Set(p);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const clickCard = useCallback(
(v: Voucher) => (editMode ? toggleSelect(v.id) : setViewVoucher(v)),
[editMode, toggleSelect, setViewVoucher],
);
const selectAll = () => {
if (selectedVouchers.length === filteredVouchers.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredVouchers.map((v) => v.id)));
}
};
const closeModal = useCallback(() => {
setViewVoucher(null);
}, []);
const deleteVouchers = useCallback(
async (kind: "selected" | "expired") => {
setBusy(true);
const kind_word = kind === "selected" ? "" : "expired";
try {
const res =
kind === "selected"
? await api.deleteSelected([...selectedVouchers.map((v) => v.id)])
: await api.deleteSelected([...expiredIds]);
const count = res.vouchersDeleted || 0;
if (count > 0) {
notify(
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
"success",
);
setSelectedIds(new Set());
} else {
notify(`No ${kind_word} vouchers were deleted`, "info");
}
} catch {
notify(`Failed to delete ${kind_word} vouchers`, "error");
}
setBusy(false);
cancelEdit();
},
[selectedVouchers, expiredIds, cancelEdit],
);
useEffect(() => {
load();
window.addEventListener("vouchersUpdated", load);
@@ -53,67 +130,14 @@ export default function VouchersTab() {
};
}, [load, cancelEdit]);
const filteredVouchers = useMemo(() => {
if (!searchQuery.trim()) return vouchers;
const handlePrintClick = (mode: PrintMode) => {
// Prepare the data for the URL
const vouchersParam = encodeURIComponent(JSON.stringify(vouchers));
const printUrl = `/print?vouchers=${vouchersParam}&mode=${mode}`;
const query = searchQuery.toLowerCase().trim();
return vouchers.filter((voucher) =>
voucher.name?.toLowerCase().includes(query),
);
}, [vouchers, searchQuery]);
const expiredVouchers = useMemo(
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
[filteredVouchers],
);
const toggleSelect = useCallback((id: string) => {
setSelected((p) => {
const s = new Set(p);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const selectAll = () => {
if (selected.size === filteredVouchers.length) {
setSelected(new Set());
} else {
setSelected(new Set(filteredVouchers.map((v) => v.id)));
}
router.replace(printUrl);
};
const closeModal = useCallback(() => {
setViewVoucher(null);
}, []);
const deleteVouchers = useCallback(
async (kind: "selected" | "expired") => {
setBusy(true);
const kind_word = kind === "selected" ? "" : "expired";
try {
const res =
kind === "selected"
? await api.deleteSelected([...selected])
: await api.deleteSelected([...expiredVouchers]);
const count = res.vouchersDeleted || 0;
if (count > 0) {
notify(
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
"success",
);
setSelected(new Set());
} else {
notify(`No ${kind_word} vouchers were deleted`, "info");
}
} catch {
notify(`Failed to delete ${kind_word} vouchers`, "error");
}
setBusy(false);
},
[selected],
);
return (
<div className="flex-1">
<div className="mb-2">
@@ -149,30 +173,44 @@ export default function VouchersTab() {
<button
onClick={selectAll}
disabled={!filteredVouchers.length}
className="btn-secondary"
className="btn-primary"
>
Select All
</button>
<button
onClick={() => handlePrintClick("grid")}
disabled={!selectedVouchers.length}
className="btn-secondary"
>
Print (Tile)
</button>
<button
onClick={() => handlePrintClick("list")}
disabled={!selectedVouchers.length}
className="btn-secondary"
>
Print (List)
</button>
<button
onClick={() => deleteVouchers("selected")}
disabled={busy || !selected.size}
disabled={busy || !selectedVouchers.length}
className="btn-danger"
>
Delete Selected
</button>
<button
onClick={() => deleteVouchers("expired")}
disabled={busy || !expiredVouchers.length}
disabled={busy || !expiredIds.length}
className="btn-warning"
>
Delete Expired
</button>
<button onClick={cancelEdit} className="btn-secondary">
<button onClick={cancelEdit} className="btn-primary">
Cancel
</button>
{busy ? <Spinner /> : <></>}
<span className="text-sm text-secondary font-bold ml-auto">
{selected.size} selected
{selectedVouchers.length} selected
</span>
</>
)}
@@ -199,10 +237,8 @@ export default function VouchersTab() {
key={v.id}
voucher={v}
editMode={editMode}
selected={selected.has(v.id)}
onClick={() =>
editMode ? toggleSelect(v.id) : setViewVoucher(v)
}
selected={selectedVouchers.includes(v)}
onClick={clickCard}
/>
))}
</div>

View File

@@ -1,57 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { useGlobal } from "@/contexts/GlobalContext";
export type Theme = "system" | "light" | "dark";
export default function ThemeSwitcher() {
type themeType = "system" | "light" | "dark";
const [theme, setTheme] = useState<themeType>("system");
// Load saved theme
useEffect(() => {
const stored = localStorage.getItem("theme") as themeType | null;
setTheme(stored || "system");
}, []);
// Apply theme class
useEffect(() => {
const html = document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const apply = () => {
// Only disable transitions on Safari
if (isSafari) {
html.classList.add("transition-disabled");
}
const isDark = theme === "dark" || (theme === "system" && mql.matches);
html.classList.toggle("dark", isDark);
localStorage.setItem("theme", theme);
// Re-enable transitions after a brief delay on Safari
if (isSafari) {
requestAnimationFrame(() => {
setTimeout(() => {
html.classList.remove("transition-disabled");
}, 150);
});
}
};
apply();
// For system mode, listen to changes
mql.addEventListener("change", apply);
return () => {
mql.removeEventListener("change", apply);
};
}, [theme]);
const { theme, setTheme } = useGlobal();
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as any)}
onChange={(e) => setTheme(e.target.value as Theme)}
className="text-sm"
>
<option value="system"> System</option>

View File

@@ -3,9 +3,9 @@
import { copyText } from "@/utils/clipboard";
import { formatCode } from "@/utils/format";
import { notify } from "@/utils/notifications";
import { VoucherPrintWindow } from "@/components/utils/VoucherPrintContent";
import { useState } from "react";
import { Voucher } from "@/types/voucher";
import { useRouter } from "next/navigation";
type Props = {
voucher: Voucher;
@@ -15,7 +15,7 @@ type Props = {
export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
const code = formatCode(voucher.code);
const [copied, setCopied] = useState(false);
const [printing, setPrinting] = useState(false);
const router = useRouter();
const handleCopy = async () => {
if (await copyText(voucher.code)) {
@@ -27,6 +27,13 @@ export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
}
};
const handlePrint = () => {
const vouchersParam = encodeURIComponent(JSON.stringify([voucher]));
const printUrl = `/print?vouchers=${vouchersParam}&mode=list`;
router.replace(printUrl);
};
return (
<div className={`text-center ${contentClassName}`}>
<div
@@ -39,10 +46,9 @@ export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
<button onClick={handleCopy} className="btn-success">
{copied ? "Copied" : "Copy Code"}
</button>
<button onClick={() => setPrinting(true)} className="btn-primary">
<button onClick={handlePrint} className="btn-primary">
Print Voucher
</button>
{printing && <VoucherPrintWindow voucher={voucher} />}
</div>
</div>
);

View File

@@ -1,137 +0,0 @@
"use client";
import { QRCodeSVG } from "qrcode.react";
import { Voucher } from "@/types/voucher";
import {
formatBytes,
formatDuration,
formatGuestUsage,
formatSpeed,
} from "@/utils/format";
import { RenderInWindow } from "@/components/utils/RenderInWindow";
import { useGlobal } from "@/contexts/GlobalContext";
const baseStyles = `
@media print {
@page {
size: 80mm auto;
margin: 5mm;
}
}
html, body {
margin: 0;
padding: 0;
background: white !important;
color: black;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
}
.wrapper {
max-width: 80mm;
margin: 0 auto;
padding: 10px;
}
.header { text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 15px; }
.title { font-weight: bold; font-size: 16px; margin-bottom: 5px; }
.voucher-code { font-size: 18px; font-weight: bold; text-align: center; border: 2px solid #000; padding: 10px; margin: 15px 0; }
.info-row { display: flex; justify-content: space-between; margin: 5px 0; border-bottom: 1px dotted #000; }
.label { font-weight: bold; flex: 1; }
.value { flex: 1; text-align: right; }
.qr-section { text-align: center; margin: 20px 0; border-top: 2px solid #000; padding-top: 15px; }
.footer { text-align: center; margin-top: 20px; border-top: 2px solid #000; padding-top: 10px; font-size: 10px; }
`;
type Props = {
voucher: Voucher;
};
export function VoucherPrintWindow({ voucher }: Props) {
const { wifiConfig, wifiString } = useGlobal();
return (
<RenderInWindow
title={`Voucher - ${voucher.code}`}
width={500}
height={700}
onReady={(win) => {
setTimeout(() => win.print(), 100);
}}
>
<div className="wrapper">
<style>{baseStyles}</style>
<div className="header">
<div className="title">WiFi Access Voucher</div>
<div>UniFi Network</div>
</div>
<div className="voucher-code">{voucher.code}</div>
<div className="info-row">
<span className="label">Duration:</span>
<span className="value">
{formatDuration(voucher.timeLimitMinutes)}
</span>
</div>
<div className="info-row">
<span className="label">Guests:</span>
<span className="value">
{formatGuestUsage(
voucher.authorizedGuestCount,
voucher.authorizedGuestLimit,
)}
</span>
</div>
<div className="info-row">
<span className="label">Data Limit:</span>
<span className="value">
{voucher.dataUsageLimitMBytes
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
: "Unlimited"}
</span>
</div>
<div className="info-row">
<span className="label">Down Speed:</span>
<span className="value">{formatSpeed(voucher.rxRateLimitKbps)}</span>
</div>
<div className="info-row">
<span className="label">Up Speed:</span>
<span className="value">{formatSpeed(voucher.txRateLimitKbps)}</span>
</div>
{wifiConfig && (
<div className="qr-section">
<div className="font-bold mb-2">Scan to Connect</div>
{wifiString && (
<QRCodeSVG
value={wifiString}
size={140}
level="H"
marginSize={4}
title="Wi-Fi Access QR Code"
/>
)}
<div className="wifi-info mt-2 text-xs">
<strong>Network:</strong> {wifiConfig.ssid}
<br />
{wifiConfig.type === "nopass" ? (
"No Password"
) : (
<>
<strong>Password:</strong> {wifiConfig.password}
</>
)}
{wifiConfig.hidden && <div>(Hidden Network)</div>}
</div>
</div>
)}
<div className="footer">
<div>ID: {voucher.id}</div>
<div>Generated: {new Date().toLocaleString()}</div>
</div>
</div>
</RenderInWindow>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { Theme } from "@/components/utils/ThemeSwitcher";
import {
generateWifiConfig,
generateWiFiQRString,
@@ -10,6 +11,8 @@ import React, { createContext, useContext, useEffect, useState } from "react";
type GlobalContextType = {
wifiConfig: WifiConfig | null;
wifiString: string | null;
theme: Theme;
setTheme: (t: Theme) => void;
};
const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
@@ -17,23 +20,65 @@ const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [value, setValue] = useState<GlobalContextType>({
wifiConfig: null,
wifiString: null,
});
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
const [wifiString, setWifiString] = useState<string | null>(null);
const [theme, setTheme] = useState<Theme>("system");
// WiFi setup
useEffect(() => {
try {
const wifiConfig = generateWifiConfig();
const wifiString = wifiConfig ? generateWiFiQRString(wifiConfig) : null;
setValue({ wifiConfig, wifiString });
const cfg = generateWifiConfig();
const str = cfg ? generateWiFiQRString(cfg) : null;
setWifiConfig(cfg);
setWifiString(str);
} catch (e) {
console.warn(`Could not generate WiFi configuration: ${e}`);
}
}, []);
// Load theme on mount
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
// Apply theme when changed
useEffect(() => {
const html = document.documentElement;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const apply = () => {
if (isSafari) html.classList.add("transition-disabled");
const isDark = theme === "dark" || (theme === "system" && mql.matches);
html.classList.toggle("dark", isDark);
localStorage.setItem("theme", theme);
if (isSafari) {
requestAnimationFrame(() => {
setTimeout(() => html.classList.remove("transition-disabled"), 150);
});
}
};
apply();
mql.addEventListener("change", apply);
return () => mql.removeEventListener("change", apply);
}, [theme]);
return (
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
<GlobalContext.Provider
value={{
wifiConfig,
wifiString,
theme,
setTheme,
}}
>
{children}
</GlobalContext.Provider>
);
};

View File

@@ -2,6 +2,10 @@ export function formatCode(code: string) {
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
}
export function formatMaxGuests(maxGuests: number | null | undefined) {
return !maxGuests ? "Unlimited" : Math.max(maxGuests, 0);
}
export function formatDuration(m: number | null | undefined) {
if (!m) return "Unlimited";
const days = Math.floor(m / 1440),

View File

@@ -1,6 +1,6 @@
import { getRuntimeConfig } from "@/utils/runtimeConfig";
// Derive the type from the array
// Derive the type from the array for easy printing
const validWifiTypes = ["WPA", "WEP", "nopass"] as const;
type WifiType = (typeof validWifiTypes)[number];