feat: basic printing of vouchers

Added a global context for the WiFi config.
This commit is contained in:
etiennecollin
2025-08-18 19:33:43 -04:00
parent fdeb429c5a
commit 8f8fc49ba8
11 changed files with 290 additions and 59 deletions

View File

@@ -1,3 +1,4 @@
import { GlobalProvider } from "@/contexts/GlobalContext";
import "./globals.css";
import type { Metadata } from "next";
@@ -22,7 +23,9 @@ export default function RootLayout({
{/* Load runtime config */}
<script src="/runtime-config.js"></script>
</head>
<body className={`antialiased`}>{children}</body>
<body className={`antialiased`}>
<GlobalProvider>{children}</GlobalProvider>
</body>
</html>
);
}

View File

@@ -3,12 +3,12 @@
import { useEffect, useRef, useState } from "react";
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
import WifiQrModal from "@/components/modals/WifiQrModal";
import { generateWifiConfig, WifiConfig } from "@/utils/wifi";
import { useGlobal } from "@/contexts/GlobalContext";
export default function Header() {
const [showWifi, setShowWifi] = useState(false);
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
const headerRef = useRef<HTMLElement>(null);
const { wifiConfig } = useGlobal();
useEffect(() => {
// Set initial height and update on resize
@@ -26,19 +26,6 @@ export default function Header() {
return () => window.removeEventListener("resize", updateHeaderHeight);
}, []);
useEffect(() => {
const config: WifiConfig | null = (() => {
try {
return generateWifiConfig();
} catch (e) {
console.warn(`Could not generate WiFi configuration: ${e}`);
return null;
}
})();
setWifiConfig(config);
}, [generateWifiConfig]);
return (
<header
ref={headerRef}
@@ -68,10 +55,7 @@ export default function Header() {
</div>
</div>
{showWifi && wifiConfig && (
<WifiQrModal
wifiConfig={wifiConfig}
onClose={() => setShowWifi(false)}
/>
<WifiQrModal onClose={() => setShowWifi(false)} />
)}
</header>
);

View File

@@ -1,20 +1,21 @@
"use client";
import Modal from "@/components/modals/Modal";
import CopyCode from "@/components/utils/CopyCode";
import VoucherCode from "@/components/utils/VoucherCode";
import { Voucher } from "@/types/voucher";
type Props = {
code: string;
voucher: Voucher;
onClose: () => void;
};
export default function SuccessModal({ code: rawCode, onClose }: Props) {
export default function SuccessModal({ voucher, onClose }: Props) {
return (
<Modal onClose={onClose} contentClassName="max-w-sm">
<h2 className="text-2xl font-bold text-primary mb-4 text-center">
Voucher Created!
</h2>
<CopyCode rawCode={rawCode} />
<VoucherCode voucher={voucher} />
</Modal>
);
}

View File

@@ -10,7 +10,7 @@ import {
formatGuestUsage,
formatSpeed,
} from "@/utils/format";
import CopyCode from "@/components/utils/CopyCode";
import VoucherCode from "@/components/utils/VoucherCode";
import { Voucher } from "@/types/voucher";
type Props = {
@@ -50,7 +50,7 @@ export default function VoucherModal({ voucher, onClose }: Props) {
return (
<Modal onClose={onClose}>
<CopyCode rawCode={rawCode} contentClassName="mb-8" />
<VoucherCode voucher={voucher} contentClassName="mb-8" />
{loading ? (
<Spinner />
) : error || details == null ? (

View File

@@ -1,22 +1,18 @@
"use client";
import { useRef, useState, useEffect, useMemo } from "react";
import { useRef, useState, useEffect } from "react";
import Modal from "@/components/modals/Modal";
import { QRCodeSVG } from "qrcode.react";
import { generateWiFiQRString, WifiConfig } from "@/utils/wifi";
import { useGlobal } from "@/contexts/GlobalContext";
type Props = {
wifiConfig: WifiConfig;
onClose: () => void;
};
export default function WifiQrModal({ wifiConfig, onClose }: Props) {
export default function WifiQrModal({ onClose }: Props) {
const modalRef = useRef<HTMLDivElement | null>(null);
const [qrSize, setQrSize] = useState(220);
const wifiString = useMemo(
() => wifiConfig && generateWiFiQRString(wifiConfig),
[wifiConfig],
);
const { wifiConfig, wifiString } = useGlobal();
useEffect(() => {
function updateSize() {
@@ -41,7 +37,7 @@ export default function WifiQrModal({ wifiConfig, onClose }: Props) {
<h2 className="text-2xl font-bold text-primary text-center">
WiFi QR Code
</h2>
{wifiString ? (
{wifiConfig && wifiString ? (
<>
<QRCodeSVG
value={wifiString}

View File

@@ -1,7 +1,7 @@
"use client";
import SuccessModal from "@/components/modals/SuccessModal";
import { VoucherCreateData } from "@/types/voucher";
import { Voucher, VoucherCreateData } from "@/types/voucher";
import { api } from "@/utils/api";
import { map } from "@/utils/functional";
import { notify } from "@/utils/notifications";
@@ -9,7 +9,7 @@ import { useCallback, useState, FormEvent } from "react";
export default function CustomCreateTab() {
const [loading, setLoading] = useState(false);
const [newCode, setNewCode] = useState<string | null>(null);
const [newVoucher, setNewVoucher] = useState<Voucher | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -33,12 +33,15 @@ export default function CustomCreateTab() {
try {
const res = await api.createVoucher(payload);
const code = res.vouchers?.[0]?.code;
if (code) {
setNewCode(code);
const voucher = res.vouchers?.[0];
if (voucher) {
setNewVoucher(voucher);
form.reset();
} else {
notify("Voucher created, but code not found in response", "warning");
notify(
"Voucher created, but its data was found in response",
"warning",
);
}
} catch {
notify("Failed to create voucher", "error");
@@ -47,7 +50,7 @@ export default function CustomCreateTab() {
};
const closeModal = useCallback(() => {
setNewCode(null);
setNewVoucher(null);
}, []);
return (
@@ -106,7 +109,7 @@ export default function CustomCreateTab() {
{loading ? "Creating…" : "Create Custom Voucher"}
</button>
</form>
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
{newVoucher && <SuccessModal voucher={newVoucher} onClose={closeModal} />}
</div>
);
}

View File

@@ -1,14 +1,14 @@
"use client";
import SuccessModal from "@/components/modals/SuccessModal";
import { VoucherCreateData } from "@/types/voucher";
import { Voucher, VoucherCreateData } from "@/types/voucher";
import { api } from "@/utils/api";
import { notify } from "@/utils/notifications";
import { useCallback, useState, FormEvent } from "react";
export default function QuickCreateTab() {
const [loading, setLoading] = useState<boolean>(false);
const [newCode, setNewCode] = useState<string | null>(null);
const [newVoucher, setNewVoucher] = useState<Voucher | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -26,12 +26,15 @@ export default function QuickCreateTab() {
try {
const res = await api.createVoucher(payload);
const code = res.vouchers?.[0]?.code;
if (code) {
setNewCode(code);
const voucher = res.vouchers?.[0];
if (voucher) {
setNewVoucher(voucher);
form.reset();
} else {
notify("Voucher created, but code not found in response", "warning");
notify(
"Voucher created, but its data was found in response",
"warning",
);
}
} catch {
notify("Failed to create voucher", "error");
@@ -40,7 +43,7 @@ export default function QuickCreateTab() {
};
const closeModal = useCallback(() => {
setNewCode(null);
setNewVoucher(null);
}, []);
return (
@@ -63,7 +66,7 @@ export default function QuickCreateTab() {
</button>
</form>
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
{newVoucher && <SuccessModal voucher={newVoucher} onClose={closeModal} />}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface RenderInWindowProps {
children: React.ReactNode;
title?: string;
width?: number;
height?: number;
onReady?: (win: Window) => void; // callback once popup is ready
}
export const RenderInWindow: React.FC<RenderInWindowProps> = ({
children,
title = "Popup Window",
width = 600,
height = 400,
onReady,
}) => {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const newWindowRef = useRef<Window | null>(null);
useEffect(() => {
const div = document.createElement("div");
setContainer(div);
}, []);
useEffect(() => {
if (!container) return;
newWindowRef.current = window.open(
"",
title,
`width=${width},height=${height},left=200,top=200`,
);
const win = newWindowRef.current;
if (!win) return;
win.document.body.appendChild(container);
// Let parent know window is ready
if (onReady) onReady(win);
return () => {
win.close();
};
}, [container, title, width, height, onReady]);
return container ? createPortal(children, container) : null;
};

View File

@@ -3,19 +3,22 @@
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";
type Props = {
rawCode: string;
voucher: Voucher;
contentClassName?: string;
};
export default function CopyCode({ rawCode, contentClassName = "" }: Props) {
const code = formatCode(rawCode);
export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
const code = formatCode(voucher.code);
const [copied, setCopied] = useState(false);
const [printing, setPrinting] = useState(false);
const handleCopy = async () => {
if (await copyText(rawCode)) {
if (await copyText(voucher.code)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
notify("Code copied to clipboard!", "success");
@@ -32,10 +35,15 @@ export default function CopyCode({ rawCode, contentClassName = "" }: Props) {
>
{code}
</div>
<button onClick={handleCopy} className="btn-success w-2/3">
{copied ? "Copied" : "Copy Code"}
</button>
<div className="flex-center gap-3">
<button onClick={handleCopy} className="btn-success">
{copied ? "Copied" : "Copy Code"}
</button>
<button onClick={() => setPrinting(true)} className="btn-primary">
Print Voucher
</button>
{printing && <VoucherPrintWindow voucher={voucher} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"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

@@ -0,0 +1,44 @@
"use client";
import {
generateWifiConfig,
generateWiFiQRString,
WifiConfig,
} from "@/utils/wifi";
import React, { createContext, useContext, useEffect, useState } from "react";
type GlobalContextType = {
wifiConfig: WifiConfig | null;
wifiString: string | null;
};
const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [value, setValue] = useState<GlobalContextType>({
wifiConfig: null,
wifiString: null,
});
useEffect(() => {
try {
const wifiConfig = generateWifiConfig();
const wifiString = wifiConfig ? generateWiFiQRString(wifiConfig) : null;
setValue({ wifiConfig, wifiString });
} catch (e) {
console.warn(`Could not generate WiFi configuration: ${e}`);
}
}, []);
return (
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
);
};
export const useGlobal = () => {
const ctx = useContext(GlobalContext);
if (!ctx) throw new Error("useGlobal must be used within GlobalProvider");
return ctx;
};