diff --git a/frontend/src/app/print/page.tsx b/frontend/src/app/print/page.tsx
new file mode 100644
index 0000000..4248695
--- /dev/null
+++ b/frontend/src/app/print/page.tsx
@@ -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 (
+
+
+
+
{formatCode(voucher.code)}
+
+
+ Duration:
+
+ {formatDuration(voucher.timeLimitMinutes)}
+
+
+
+ Max Guests:
+
+ {formatMaxGuests(voucher.authorizedGuestLimit)}
+
+
+
+ Data Limit:
+
+ {voucher.dataUsageLimitMBytes
+ ? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
+ : "Unlimited"}
+
+
+
+ Down Speed:
+
+ {formatSpeed(voucher.rxRateLimitKbps)}
+
+
+
+ Up Speed:
+
+ {formatSpeed(voucher.txRateLimitKbps)}
+
+
+
+ {wifiConfig && (
+
+ {wifiString && (
+ <>
+
Scan to Connect
+
+ >
+ )}
+
+
Network: {wifiConfig.ssid}
+
+ {wifiConfig.type === "nopass" ? (
+ "No Password"
+ ) : (
+ <>
+
Password: {wifiConfig.password}
+ >
+ )}
+ {wifiConfig.hidden &&
(Hidden Network)
}
+
+
+ )}
+
+
+
+ ID: {voucher.id}
+
+
+ Printed:{" "}
+ {new Date().toUTCString()}
+
+
+
+ );
+}
+
+function Vouchers() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [vouchers, setVouchers] = useState([]);
+ const [mode, setMode] = useState("list");
+ const lastSearchParams = useRef(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 ? (
+
+ No vouchers to print, press backspace
+
+ ) : (
+
+ {vouchers.map((v) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+ Loading...
}
+ >
+
+
+
+ );
+}
diff --git a/frontend/src/app/print/styles.css b/frontend/src/app/print/styles.css
new file mode 100644
index 0000000..6572d40
--- /dev/null
+++ b/frontend/src/app/print/styles.css
@@ -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;
+}
diff --git a/frontend/src/components/VoucherCard.tsx b/frontend/src/components/VoucherCard.tsx
index 2a95988..6f2b373 100644
--- a/frontend/src/components/VoucherCard.tsx
+++ b/frontend/src/components/VoucherCard.tsx
@@ -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 (
{
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
diff --git a/frontend/src/components/modals/VoucherModal.tsx b/frontend/src/components/modals/VoucherModal.tsx
index 01de2b1..346daec 100644
--- a/frontend/src/components/modals/VoucherModal.tsx
+++ b/frontend/src/components/modals/VoucherModal.tsx
@@ -46,8 +46,6 @@ export default function VoucherModal({ voucher, onClose }: Props) {
})();
}, [voucher.id]);
- const rawCode = details?.code ?? voucher.code;
-
return (
diff --git a/frontend/src/components/tabs/VouchersTab.tsx b/frontend/src/components/tabs/VouchersTab.tsx
index 0d80292..538c1fe 100644
--- a/frontend/src/components/tabs/VouchersTab.tsx
+++ b/frontend/src/components/tabs/VouchersTab.tsx
@@ -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(null);
const [searchQuery, setSearchQuery] = useState("");
const [editMode, setEditMode] = useState(false);
- const [selected, setSelected] = useState>(new Set());
+ const [selectedIds, setSelectedIds] = useState>(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 (
@@ -149,30 +173,44 @@ export default function VouchersTab() {
+
+
-
diff --git a/frontend/src/components/utils/ThemeSwitcher.tsx b/frontend/src/components/utils/ThemeSwitcher.tsx
index 5627d32..a564cf0 100644
--- a/frontend/src/components/utils/ThemeSwitcher.tsx
+++ b/frontend/src/components/utils/ThemeSwitcher.tsx
@@ -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
("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 (