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 ( +
+
+
WiFi Access Voucher
+
+ +
{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() { + + - {busy ? : <>} - {selected.size} selected + {selectedVouchers.length} selected )} @@ -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} /> ))}
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 (