mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +00:00
feat: bulk printing of vouchers
This commit is contained in:
176
frontend/src/app/print/page.tsx
Normal file
176
frontend/src/app/print/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
67
frontend/src/app/print/styles.css
Normal file
67
frontend/src/app/print/styles.css
Normal 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;
|
||||
}
|
@@ -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" : ""}`}
|
||||
|
@@ -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";
|
||||
|
@@ -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" />
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -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),
|
||||
|
@@ -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];
|
||||
|
||||
|
Reference in New Issue
Block a user