mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 08:12:15 +00:00
feat: basic printing of vouchers
Added a global context for the WiFi config.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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 ? (
|
||||
|
@@ -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">
|
||||
Wi‑Fi QR Code
|
||||
</h2>
|
||||
{wifiString ? (
|
||||
{wifiConfig && wifiString ? (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
value={wifiString}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
52
frontend/src/components/utils/RenderInWindow.tsx
Normal file
52
frontend/src/components/utils/RenderInWindow.tsx
Normal 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;
|
||||
};
|
@@ -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">
|
||||
<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>
|
||||
);
|
||||
}
|
137
frontend/src/components/utils/VoucherPrintContent.tsx
Normal file
137
frontend/src/components/utils/VoucherPrintContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
frontend/src/contexts/GlobalContext.tsx
Normal file
44
frontend/src/contexts/GlobalContext.tsx
Normal 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;
|
||||
};
|
Reference in New Issue
Block a user