mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +00:00
211
frontend/package-lock.json
generated
211
frontend/package-lock.json
generated
@@ -35,20 +35,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
|
||||
@@ -491,9 +477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
|
||||
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -501,6 +487,17 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -512,16 +509,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
|
||||
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
|
||||
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
|
||||
"version": "0.3.30",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
|
||||
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -673,25 +670,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
||||
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz",
|
||||
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"enhanced-resolve": "^5.18.1",
|
||||
"jiti": "^2.4.2",
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.5.1",
|
||||
"lightningcss": "1.30.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.11"
|
||||
"tailwindcss": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
|
||||
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz",
|
||||
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -703,24 +700,24 @@
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.11",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.11",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.11",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.11",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.11",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.11",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.11",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.11"
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.12",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.12",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.12",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.12",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.12",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz",
|
||||
"integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz",
|
||||
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -735,9 +732,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz",
|
||||
"integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz",
|
||||
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -752,9 +749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz",
|
||||
"integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz",
|
||||
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -769,9 +766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz",
|
||||
"integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz",
|
||||
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -786,9 +783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz",
|
||||
"integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz",
|
||||
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -803,9 +800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz",
|
||||
"integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz",
|
||||
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -820,9 +817,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz",
|
||||
"integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz",
|
||||
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -837,9 +834,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz",
|
||||
"integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz",
|
||||
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -854,9 +851,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz",
|
||||
"integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz",
|
||||
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -871,9 +868,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz",
|
||||
"integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz",
|
||||
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -889,11 +886,11 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@emnapi/wasi-threads": "^1.0.2",
|
||||
"@napi-rs/wasm-runtime": "^0.2.11",
|
||||
"@tybys/wasm-util": "^0.9.0",
|
||||
"@emnapi/core": "^1.4.5",
|
||||
"@emnapi/runtime": "^1.4.5",
|
||||
"@emnapi/wasi-threads": "^1.0.4",
|
||||
"@napi-rs/wasm-runtime": "^0.2.12",
|
||||
"@tybys/wasm-util": "^0.10.0",
|
||||
"tslib": "^2.8.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -901,9 +898,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
|
||||
"integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
||||
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -918,9 +915,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz",
|
||||
"integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz",
|
||||
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -935,23 +932,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
|
||||
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz",
|
||||
"integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.11",
|
||||
"@tailwindcss/oxide": "4.1.11",
|
||||
"@tailwindcss/node": "4.1.12",
|
||||
"@tailwindcss/oxide": "4.1.12",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.11"
|
||||
"tailwindcss": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
|
||||
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
|
||||
"version": "20.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
|
||||
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -959,9 +956,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
|
||||
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
|
||||
"version": "19.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
|
||||
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -979,9 +976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001731",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz",
|
||||
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==",
|
||||
"version": "1.0.30001735",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz",
|
||||
"integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1077,9 +1074,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
|
||||
"integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==",
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1670,9 +1667,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
|
||||
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
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;
|
||||
}
|
@@ -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,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";
|
||||
|
@@ -1,20 +1,19 @@
|
||||
"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 = {
|
||||
@@ -46,11 +46,9 @@ export default function VoucherModal({ voucher, onClose }: Props) {
|
||||
})();
|
||||
}, [voucher.id]);
|
||||
|
||||
const rawCode = details?.code ?? voucher.code;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@@ -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,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { copyText } from "@/utils/clipboard";
|
||||
import { formatCode } from "@/utils/format";
|
||||
import { notify } from "@/utils/notifications";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
rawCode: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export default function CopyCode({ rawCode, contentClassName = "" }: Props) {
|
||||
const code = formatCode(rawCode);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (await copyText(rawCode)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
notify("Code copied to clipboard!", "success");
|
||||
} else {
|
||||
notify("Failed to copy code", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`text-center ${contentClassName}`}>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="cursor-pointer mb-4 text-3xl voucher-code"
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
|
||||
<button onClick={handleCopy} className="btn-success w-2/3">
|
||||
{copied ? "Copied" : "Copy Code"}
|
||||
</button>
|
||||
</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;
|
||||
};
|
@@ -1,57 +1,14 @@
|
||||
"use client";
|
||||
import { useGlobal } from "@/contexts/GlobalContext";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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>
|
||||
|
53
frontend/src/components/utils/VoucherCode.tsx
Normal file
53
frontend/src/components/utils/VoucherCode.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { copyText } from "@/utils/clipboard";
|
||||
import { formatCode } from "@/utils/format";
|
||||
import { notify } from "@/utils/notifications";
|
||||
import { useState } from "react";
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
voucher: Voucher;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
|
||||
const code = formatCode(voucher.code);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (await copyText(voucher.code)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
notify("Code copied to clipboard!", "success");
|
||||
} else {
|
||||
notify("Failed to copy code", "error");
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
onClick={handleCopy}
|
||||
className="cursor-pointer mb-4 text-3xl voucher-code"
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
<div className="flex-center gap-3">
|
||||
<button onClick={handleCopy} className="btn-success">
|
||||
{copied ? "Copied" : "Copy Code"}
|
||||
</button>
|
||||
<button onClick={handlePrint} className="btn-primary">
|
||||
Print Voucher
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
89
frontend/src/contexts/GlobalContext.tsx
Normal file
89
frontend/src/contexts/GlobalContext.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { Theme } from "@/components/utils/ThemeSwitcher";
|
||||
import {
|
||||
generateWifiConfig,
|
||||
generateWiFiQRString,
|
||||
WifiConfig,
|
||||
} from "@/utils/wifi";
|
||||
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);
|
||||
|
||||
export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
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 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={{
|
||||
wifiConfig,
|
||||
wifiString,
|
||||
theme,
|
||||
setTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GlobalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGlobal = () => {
|
||||
const ctx = useContext(GlobalContext);
|
||||
if (!ctx) throw new Error("useGlobal must be used within GlobalProvider");
|
||||
return ctx;
|
||||
};
|
@@ -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];
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2018",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
Reference in New Issue
Block a user