mirror of
https://github.com/glenndehaan/unifi-voucher-site.git
synced 2026-04-05 08:54:17 -04:00
Implemented expired states within templates. Implemented expired filter. Implemented notes within voucher creation. Implemented Notes within voucher overview and detail pages. Implemented notes sort option. Fixed incorrect voucher type when custom quotas are in use. Implemented quota display within templates. Implemented bulk printing for both PDF and ESC/POS modules. Updated README.md
This commit is contained in:
@@ -15,7 +15,8 @@ UniFi Voucher Site is a web-based platform for generating and managing UniFi net
|
||||
- **Web and API Services**: Access the service via a web interface or integrate with other systems using a REST API.
|
||||
- **Docker Support**: Easily deploy using Docker, with customizable environment settings.
|
||||
- **Home Assistant Add-on**: Seamlessly integrate with Home Assistant for centralized management.
|
||||
- **Receipt Printing**: Supports printing vouchers with 80mm thermal printers.
|
||||
- **Receipt Printing**: Supports printing vouchers with 80mm thermal printers. Via compatible PDFs or ESC/POS enabled network printers.
|
||||
- **Bulk Printing**: Export/print multiple Vouchers in one go.
|
||||
- **Email Functionality**: Automatically send vouchers via SMTP.
|
||||
- **Localized Email/Print Templates** Fully localized templates, with support for multiple languages.
|
||||
- **Scan to Connect QR Codes** Quickly connect users via a phone's camera. (Available within Email and Print Layouts)
|
||||
|
||||
@@ -27,18 +27,27 @@ module.exports = {
|
||||
/**
|
||||
* Generates a voucher as a PDF
|
||||
*
|
||||
* @param voucher
|
||||
* @param content
|
||||
* @param language
|
||||
* @param multiPage
|
||||
* @return {Promise<unknown>}
|
||||
*/
|
||||
pdf: (voucher, language) => {
|
||||
pdf: (content, language, multiPage= false) => {
|
||||
return new Promise(async (resolve) => {
|
||||
// Create new translator
|
||||
const t = translation('print', language);
|
||||
|
||||
// Set vouchers based on multiPage parameter
|
||||
let vouchers = [];
|
||||
if(multiPage) {
|
||||
vouchers = [...content];
|
||||
} else {
|
||||
vouchers = [content];
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({
|
||||
bufferPages: true,
|
||||
size: [226.77165354330398, size(voucher)],
|
||||
size: [226.77165354330398, size(vouchers[0])],
|
||||
margins : {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
@@ -54,7 +63,26 @@ module.exports = {
|
||||
resolve(buffers);
|
||||
});
|
||||
|
||||
doc.image('public/images/logo_grayscale_dark.png', 75, 15, {fit: [75, 75], align: 'center', valign: 'center'});
|
||||
for(let item = 0; item < vouchers.length; item++) {
|
||||
if(item > 0) {
|
||||
doc.addPage({
|
||||
size: [226.77165354330398, size(vouchers[item])],
|
||||
margins : {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20
|
||||
}
|
||||
});
|
||||
|
||||
doc.moveDown(1);
|
||||
}
|
||||
|
||||
doc.image('public/images/logo_grayscale_dark.png', 75, 15, {
|
||||
fit: [75, 75],
|
||||
align: 'center',
|
||||
valign: 'center'
|
||||
});
|
||||
doc.moveDown(6);
|
||||
|
||||
doc.font('Helvetica-Bold')
|
||||
@@ -64,7 +92,7 @@ module.exports = {
|
||||
});
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(15)
|
||||
.text(`${voucher.code.slice(0, 5)}-${voucher.code.slice(5)}`, {
|
||||
.text(`${vouchers[item].code.slice(0, 5)}-${vouchers[item].code.slice(5)}`, {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
@@ -109,7 +137,11 @@ module.exports = {
|
||||
.fontSize(10)
|
||||
.text(`${t('scan')}:`);
|
||||
|
||||
doc.image(await qr(), 75, variables.unifiSsidPassword !== '' ? 215 : 205, {fit: [75, 75], align: 'center', valign: 'center'});
|
||||
doc.image(await qr(), 75, variables.unifiSsidPassword !== '' ? 215 : 205, {
|
||||
fit: [75, 75],
|
||||
align: 'center',
|
||||
valign: 'center'
|
||||
});
|
||||
doc.moveDown(6);
|
||||
|
||||
doc.moveDown(2);
|
||||
@@ -130,7 +162,7 @@ module.exports = {
|
||||
});
|
||||
doc.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.text(voucher.quota === 0 ? t('multiUse') : t('singleUse'));
|
||||
.text(vouchers[item].quota === 1 ? t('singleUse') : vouchers[item].quota === 0 ? t('multiUse') : t('multiUse'));
|
||||
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(10)
|
||||
@@ -139,9 +171,9 @@ module.exports = {
|
||||
});
|
||||
doc.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.text(time(voucher.duration));
|
||||
.text(time(vouchers[item].duration));
|
||||
|
||||
if(voucher.qos_usage_quota) {
|
||||
if (vouchers[item].qos_usage_quota) {
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(10)
|
||||
.text(`${t('dataLimit')}: `, {
|
||||
@@ -149,10 +181,10 @@ module.exports = {
|
||||
});
|
||||
doc.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.text(`${bytes(voucher.qos_usage_quota, 2)}`);
|
||||
.text(`${bytes(vouchers[item].qos_usage_quota, 2)}`);
|
||||
}
|
||||
|
||||
if(voucher.qos_rate_max_down) {
|
||||
if (vouchers[item].qos_rate_max_down) {
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(10)
|
||||
.text(`${t('downloadLimit')}: `, {
|
||||
@@ -160,10 +192,10 @@ module.exports = {
|
||||
});
|
||||
doc.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.text(`${bytes(voucher.qos_rate_max_down, 1, true)}`);
|
||||
.text(`${bytes(vouchers[item].qos_rate_max_down, 1, true)}`);
|
||||
}
|
||||
|
||||
if(voucher.qos_rate_max_up) {
|
||||
if (vouchers[item].qos_rate_max_up) {
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(10)
|
||||
.text(`${t('uploadLimit')}: `, {
|
||||
@@ -171,7 +203,8 @@ module.exports = {
|
||||
});
|
||||
doc.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.text(`${bytes(voucher.qos_rate_max_up, 1, true)}`);
|
||||
.text(`${bytes(vouchers[item].qos_rate_max_up, 1, true)}`);
|
||||
}
|
||||
}
|
||||
|
||||
doc.end();
|
||||
@@ -260,7 +293,7 @@ module.exports = {
|
||||
printer.invert(true);
|
||||
printer.print(`${t('type')}:`);
|
||||
printer.invert(false);
|
||||
printer.print(voucher.quota === 0 ? ` ${t('multiUse')}` : ` ${t('singleUse')}`);
|
||||
printer.print(voucher.quota === 1 ? ` ${t('singleUse')}` : voucher.quota === 0 ? ` ${t('multiUse')}` : ` ${t('multiUse')}`);
|
||||
printer.newLine();
|
||||
|
||||
printer.setTextDoubleHeight();
|
||||
@@ -307,7 +340,11 @@ module.exports = {
|
||||
try {
|
||||
await printer.execute();
|
||||
log.info('[Printer] Data send to printer!');
|
||||
|
||||
// Ensure cheap printers have cleared the buffer before allowing new actions
|
||||
setTimeout(() => {
|
||||
resolve(true);
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const startSession = () => {
|
||||
/**
|
||||
* UniFi module functions
|
||||
*
|
||||
* @type {{create: (function(*, number=, boolean=): Promise<*>), list: (function(boolean=): Promise<*>), remove: (function(*, boolean=): Promise<*>)}}
|
||||
* @type {{create: (function(*, number=, null=, boolean=): Promise<*>), remove: (function(*, boolean=): Promise<*>), list: (function(boolean=): Promise<*>), guests: (function(boolean=): Promise<*>)}}
|
||||
*/
|
||||
const unifiModule = {
|
||||
/**
|
||||
@@ -76,13 +76,14 @@ const unifiModule = {
|
||||
*
|
||||
* @param type
|
||||
* @param amount
|
||||
* @param note
|
||||
* @param retry
|
||||
* @return {Promise<unknown>}
|
||||
*/
|
||||
create: (type, amount = 1, retry = true) => {
|
||||
create: (type, amount = 1, note = null, retry = true) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSession().then(() => {
|
||||
controller.createVouchers(type.expiration, amount, parseInt(type.usage) === 1 ? 1 : 0, null, typeof type.upload !== "undefined" ? type.upload : null, typeof type.download !== "undefined" ? type.download : null, typeof type.megabytes !== "undefined" ? type.megabytes : null).then((voucher_data) => {
|
||||
controller.createVouchers(type.expiration, amount, parseInt(type.usage) === 1 ? 1 : 0, note, typeof type.upload !== "undefined" ? type.upload : null, typeof type.download !== "undefined" ? type.download : null, typeof type.megabytes !== "undefined" ? type.megabytes : null).then((voucher_data) => {
|
||||
if(amount > 1) {
|
||||
log.info(`[UniFi] Created ${amount} vouchers`);
|
||||
resolve(true);
|
||||
@@ -107,7 +108,7 @@ const unifiModule = {
|
||||
log.info('[UniFi] Attempting re-authentication & retry...');
|
||||
|
||||
controller = null;
|
||||
unifiModule.create(type, amount, false).then((e) => {
|
||||
unifiModule.create(type, amount, note, false).then((e) => {
|
||||
resolve(e);
|
||||
}).catch((e) => {
|
||||
reject(e);
|
||||
|
||||
91
server.js
91
server.js
@@ -203,7 +203,7 @@ if(variables.serviceWeb) {
|
||||
}
|
||||
|
||||
// Create voucher code
|
||||
const voucherCode = await unifi.create(types(req.body['voucher-type'] === 'custom' ? `${req.body['voucher-duration']},${req.body['voucher-usage']},${req.body['voucher-upload-limit']},${req.body['voucher-download-limit']},${req.body['voucher-data-limit']};` : req.body['voucher-type'], true), parseInt(req.body['voucher-amount'])).catch((e) => {
|
||||
const voucherCode = await unifi.create(types(req.body['voucher-type'] === 'custom' ? `${req.body['voucher-duration']},${req.body['voucher-usage']},${req.body['voucher-upload-limit']},${req.body['voucher-download-limit']},${req.body['voucher-data-limit']};` : req.body['voucher-type'], true), parseInt(req.body['voucher-amount']), req.body['voucher-note'] !== '' ? req.body['voucher-note'] : null).catch((e) => {
|
||||
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
|
||||
});
|
||||
|
||||
@@ -419,11 +419,15 @@ if(variables.serviceWeb) {
|
||||
voucher_custom: variables.voucherCustom,
|
||||
vouchers: cache.vouchers.filter((item) => {
|
||||
if(req.query.status === 'available') {
|
||||
return item.used === 0;
|
||||
return item.used === 0 && item.status !== 'EXPIRED';
|
||||
}
|
||||
|
||||
if(req.query.status === 'in-use') {
|
||||
return item.used > 0;
|
||||
return item.used > 0 && item.status !== 'EXPIRED';
|
||||
}
|
||||
|
||||
if(req.query.status === 'expired') {
|
||||
return item.status === 'EXPIRED';
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -443,6 +447,11 @@ if(variables.serviceWeb) {
|
||||
if (a.code < b.code) return 1;
|
||||
}
|
||||
|
||||
if(req.query.sort === 'note') {
|
||||
if ((a.note || '') > (b.note || '')) return -1;
|
||||
if ((a.note || '') < (b.note || '')) return 1;
|
||||
}
|
||||
|
||||
if(req.query.sort === 'duration') {
|
||||
if (a.duration > b.duration) return -1;
|
||||
if (a.duration < b.duration) return 1;
|
||||
@@ -499,6 +508,80 @@ if(variables.serviceWeb) {
|
||||
status: status()
|
||||
});
|
||||
});
|
||||
app.get('/bulk/print', [authorization.web], async (req, res) => {
|
||||
if(variables.printerType === '') {
|
||||
res.status(501).send();
|
||||
return;
|
||||
}
|
||||
|
||||
res.render('components/bulk-print', {
|
||||
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '',
|
||||
timeConvert: time,
|
||||
bytesConvert: bytes,
|
||||
languages,
|
||||
defaultLanguage: variables.translationDefault,
|
||||
vouchers: cache.vouchers,
|
||||
updated: cache.updated
|
||||
});
|
||||
});
|
||||
app.post('/bulk/print', [authorization.web], async (req, res) => {
|
||||
if(variables.printerType === '') {
|
||||
res.status(501).send();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!req.body.vouchers) {
|
||||
res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'No selected vouchers to print!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single checkboxes get send as string so conversion is needed
|
||||
if(typeof req.body.vouchers === 'string') {
|
||||
req.body.vouchers = [req.body.vouchers];
|
||||
}
|
||||
|
||||
const vouchers = req.body.vouchers.map((voucher) => {
|
||||
return cache.vouchers.find((e) => {
|
||||
return e._id === voucher;
|
||||
});
|
||||
});
|
||||
|
||||
if(!vouchers.includes(undefined)) {
|
||||
if(variables.printerType === 'pdf') {
|
||||
const buffers = await print.pdf(vouchers, req.body.language, true);
|
||||
const pdfData = Buffer.concat(buffers);
|
||||
res.writeHead(200, {
|
||||
'Content-Length': Buffer.byteLength(pdfData),
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment;filename=bulk_vouchers_${new Date().getTime()}.pdf`
|
||||
}).end(pdfData);
|
||||
}
|
||||
|
||||
if(variables.printerType === 'escpos') {
|
||||
let printSuccess = true;
|
||||
|
||||
for(let voucher = 0; voucher < vouchers.length; voucher++) {
|
||||
const printResult = await print.escpos(vouchers[voucher], req.body.language).catch((e) => {
|
||||
res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
|
||||
});
|
||||
|
||||
if(!printResult) {
|
||||
printSuccess = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(printSuccess) {
|
||||
res.cookie('flashMessage', JSON.stringify({type: 'info', message: `Vouchers send to printer!`}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res.status(404);
|
||||
res.render('404', {
|
||||
baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if(variables.serviceApi) {
|
||||
@@ -564,7 +647,7 @@ if(variables.serviceApi) {
|
||||
vouchers: cache.vouchers.map((voucher) => {
|
||||
return {
|
||||
code: `${voucher.code.slice(0, 5)}-${voucher.code.slice(5)}`,
|
||||
type: voucher.quota === 0 ? 'multi' : 'single',
|
||||
type: voucher.quota === 1 ? 'single' : voucher.quota === 0 ? 'multi' : 'multi',
|
||||
duration: voucher.duration,
|
||||
data_limit: voucher.qos_usage_quota ? voucher.qos_usage_quota : null,
|
||||
download_limit: voucher.qos_rate_max_down ? voucher.qos_rate_max_down : null,
|
||||
|
||||
108
template/components/bulk-print.ejs
Normal file
108
template/components/bulk-print.ejs
Normal file
@@ -0,0 +1,108 @@
|
||||
<div class="relative z-40" role="dialog" aria-modal="true">
|
||||
<div id="overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
|
||||
|
||||
<div class="fixed overflow-hidden">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16">
|
||||
<div class="pointer-events-auto w-screen max-w-md">
|
||||
<form id="print-form" class="flex h-full flex-col divide-y divide-black/5 dark:divide-white/5 bg-white dark:bg-gray-900 shadow-xl" action="<%= baseUrl %>/bulk/print" method="post" enctype="multipart/form-data">
|
||||
<div class="h-0 flex-1 overflow-y-auto">
|
||||
<div class="bg-sky-700 px-4 py-6 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold leading-6 text-white" id="slide-over-title">Bulk Print Vouchers</h2>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<p class="text-sm text-sky-100">Get started by filling in the information below to print multiple vouchers.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col justify-between">
|
||||
<div class="divide-y divide-black/5 dark:divide-white/5 px-4 sm:px-6">
|
||||
<div class="space-y-6 pb-5 pt-6">
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Language</label>
|
||||
<div class="mt-2">
|
||||
<select id="language" name="language" class="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black">
|
||||
<% Object.keys(languages).forEach((language) => { %>
|
||||
<option value="<%= language %>"<%= language === defaultLanguage ? ' selected' : '' %>><%= languages[language] %> (<%= language %>)</option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Vouchers</span>
|
||||
<ul role="list" class="max-h-96 h-96 overflow-y-auto mt-2 rounded-md border-0 divide-y divide-black/5 dark:divide-white/5 dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10">
|
||||
<% vouchers.forEach((voucher) => { %>
|
||||
<li class="relative flex items-center space-x-4 px-4 py-4">
|
||||
<input id="voucher-<%= voucher._id %>" aria-describedby="voucher-<%= voucher._id %>" name="vouchers" type="checkbox" value="<%= voucher._id %>" class="col-start-1 row-start-1 appearance-none rounded border-0 dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 focus:ring-offset-0">
|
||||
<label for="voucher-<%= voucher._id %>" class="voucher min-w-0 flex-auto">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h2 class="min-w-0 text-sm font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
<div class="flex gap-x-2">
|
||||
<span class="tabular-nums pointer-events-none no-underline text-inherit"><%= voucher.code.slice(0, 5) %>-<%= voucher.code.slice(5) %></span>
|
||||
<% if (voucher.status === 'EXPIRED') { %>
|
||||
<div class="rounded-full w-fit py-1 px-2 text-xs font-medium ring-1 ring-inset bg-red-50 text-red-800 ring-red-600/20 dark:text-red-400 dark:bg-red-400/10 dark:ring-red-400/20">
|
||||
Expired
|
||||
</div>
|
||||
<% } else {%>
|
||||
<% if(voucher.used > 0) { %>
|
||||
<div class="rounded-full flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-yellow-50 text-yellow-800 ring-yellow-600/20 dark:text-yellow-400 dark:bg-yellow-400/10 dark:ring-yellow-400/20">
|
||||
In Use
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="rounded-full flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-green-50 text-green-700 ring-green-600/20 dark:text-green-400 dark:bg-green-400/10 dark:ring-green-400/20">
|
||||
Available
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if (voucher.note) { %>
|
||||
<div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20">
|
||||
<%= voucher.note %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-x-2.5 text-xs leading-5 text-gray-600 dark:text-gray-400">
|
||||
<p class="whitespace-nowrap"><%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %></p>
|
||||
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 flex-none fill-gray-500 dark:fill-gray-300">
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<p class="whitespace-nowrap"><%= timeConvert(voucher.duration) %></p>
|
||||
<% if(voucher.qos_usage_quota) { %>
|
||||
<svg viewBox="0 0 2 2" class="hidden sm:block h-0.5 w-0.5 flex-none fill-gray-500 dark:fill-gray-300">
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<p class="hidden sm:block truncate"><%= bytesConvert(voucher.qos_usage_quota, 2) %> Data Limit</p>
|
||||
<% } %>
|
||||
<% if(voucher.qos_rate_max_down) { %>
|
||||
<svg viewBox="0 0 2 2" class="hidden sm:block h-0.5 w-0.5 flex-none fill-gray-500 dark:fill-gray-300">
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<p class="hidden sm:block truncate"><%= bytesConvert(voucher.qos_rate_max_down, 1, true) %> Download Limit</p>
|
||||
<% } %>
|
||||
<% if(voucher.qos_rate_max_up) { %>
|
||||
<svg viewBox="0 0 2 2" class="hidden sm:block h-0.5 w-0.5 flex-none fill-gray-500 dark:fill-gray-300">
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<p class=" hidden sm:block truncate"><%= bytesConvert(voucher.qos_rate_max_up, 1, true) %> Upload Limit</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 justify-end px-4 py-4">
|
||||
<button id="close" type="button" class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" class="ml-4 inline-flex justify-center rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Print</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,6 +26,11 @@
|
||||
<div class="py-2 grid grid-cols-3 gap-4 px-0">
|
||||
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Status</dt>
|
||||
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0">
|
||||
<% if (voucher.status === 'EXPIRED') { %>
|
||||
<div class="rounded-full w-fit py-1 px-2 text-xs font-medium ring-1 ring-inset bg-red-50 text-red-800 ring-red-600/20 dark:text-red-400 dark:bg-red-400/10 dark:ring-red-400/20">
|
||||
Expired
|
||||
</div>
|
||||
<% } else {%>
|
||||
<% if(voucher.used > 0) { %>
|
||||
<div class="rounded-full w-fit py-1 px-2 text-xs font-medium ring-1 ring-inset bg-yellow-50 text-yellow-800 ring-yellow-600/20 dark:text-yellow-400 dark:bg-yellow-400/10 dark:ring-yellow-400/20">
|
||||
In Use
|
||||
@@ -35,11 +40,16 @@
|
||||
Available
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-2 grid grid-cols-3 gap-4 px-0">
|
||||
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Notes</dt>
|
||||
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= voucher.note ? voucher.note : '-' %></dd>
|
||||
</div>
|
||||
<div class="py-2 grid grid-cols-3 gap-4 px-0">
|
||||
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Type</dt>
|
||||
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= voucher.quota === 0 ? 'Multi-use' : 'Single-use' %></dd>
|
||||
<dd class="text-sm leading-6 text-gray-600 dark:text-gray-400 col-span-2 mt-0"><%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %></dd>
|
||||
</div>
|
||||
<div class="py-2 grid grid-cols-3 gap-4 px-0">
|
||||
<dt class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Duration</dt>
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<% } %>
|
||||
<p style="font-family: sans-serif; font-size: 20px; font-weight: bold; margin: 0; margin-bottom: 15px;"><%= t('details') %></p>
|
||||
<hr/>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0;"><span style="font-weight: bold;"><%= t('type') %>:</span> <%= voucher.quota === 0 ? t('multiUse') : t('singleUse') %></p>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0;"><span style="font-weight: bold;"><%= t('type') %>:</span> <%= voucher.quota === 1 ? t('singleUse') : voucher.quota === 0 ? t('multiUse') : t('multiUse') %></p>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0;"><span style="font-weight: bold;"><%= t('duration') %>:</span> <%= timeConvert(voucher.duration) %></p>
|
||||
<% if(voucher.qos_usage_quota) { %>
|
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0;"><span style="font-weight: bold;"><%= t('dataLimit') %>:</span> <%= bytesConvert(voucher.qos_usage_quota, 2) %></p>
|
||||
|
||||
@@ -66,16 +66,17 @@
|
||||
<% } %>
|
||||
|
||||
<main class="mx-auto max-w-7xl">
|
||||
<header class="flex items-center justify-between border-b border-black/5 dark:border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
|
||||
<header class="flex items-center justify-between border-b border-black/5 dark:border-white/5 px-4 py-4 md:px-6 md:py-6 lg:px-8">
|
||||
<div class="grid">
|
||||
<form id="filter-sort-form" action="<%= baseUrl %>/vouchers" method="get">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:space-x-4 space-y-2 sm:space-y-0">
|
||||
<div class="flex flex-col md:flex-row md:items-end md:space-x-4 space-y-2 md:space-y-0">
|
||||
<div class="flex flex-col">
|
||||
<label for="status" class="text-xs text-gray-900 dark:text-white mb-1">Status</label>
|
||||
<select id="status" name="status" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black" aria-label="Filter by status">
|
||||
<option value="all"<%= filters.status === 'all' ? ' selected' : '' %>>All</option>
|
||||
<option value="available"<%= filters.status === 'available' ? ' selected' : '' %>>Available</option>
|
||||
<option value="in-use"<%= filters.status === 'in-use' ? ' selected' : '' %>>In Use</option>
|
||||
<option value="expired"<%= filters.status === 'expired' ? ' selected' : '' %>>Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@@ -91,6 +92,7 @@
|
||||
<select id="sort" name="sort" class="rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6 [&_*]:text-black" aria-label="Sort by">
|
||||
<option value="date"<%= sort === 'date' ? ' selected' : '' %>>Date</option>
|
||||
<option value="code"<%= sort === 'code' ? ' selected' : '' %>>Code</option>
|
||||
<option value="note"<%= sort === 'note' ? ' selected' : '' %>>Notes</option>
|
||||
<option value="duration"<%= sort === 'duration' ? ' selected' : '' %>>Duration</option>
|
||||
<option value="status"<%= sort === 'status' ? ' selected' : '' %>>Status</option>
|
||||
</select>
|
||||
@@ -99,7 +101,19 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid self-end">
|
||||
<div class="grid md:grid-cols-2 self-end">
|
||||
<div class="grid mb-2 md:mb-0 md:mr-4">
|
||||
<span class="mb-1 text-xs text-gray-900 dark:text-white<%= !printer_enabled ? ' hidden' : '' %>">
|
||||
|
||||
</span>
|
||||
<button id="bulk-print" class="w-fit justify-self-end relative inline-flex items-center gap-x-1.5 rounded-md bg-sky-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700<%= !printer_enabled ? ' hidden' : '' %>">
|
||||
<svg class="-ml-0.5 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Zm-3 0h.008v.008H15V10.5Z" />
|
||||
</svg>
|
||||
Bulk Print
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<span class="mb-1 text-xs text-gray-900 dark:text-white">
|
||||
Last Sync: <%= new Intl.DateTimeFormat('en-GB', {day: "numeric", month: "numeric", hour: "numeric", minute: "numeric", hour12: false}).format(new Date(updated)) %>
|
||||
</span>
|
||||
@@ -110,6 +124,7 @@
|
||||
Sync Vouchers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if(vouchers.length < 1) { %>
|
||||
@@ -136,6 +151,11 @@
|
||||
<h2 class="min-w-0 text-sm font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
<div class="flex gap-x-2">
|
||||
<span class="tabular-nums pointer-events-none no-underline text-inherit"><%= voucher.code.slice(0, 5) %>-<%= voucher.code.slice(5) %></span>
|
||||
<% if (voucher.status === 'EXPIRED') { %>
|
||||
<div class="rounded-full w-fit py-1 px-2 text-xs font-medium ring-1 ring-inset bg-red-50 text-red-800 ring-red-600/20 dark:text-red-400 dark:bg-red-400/10 dark:ring-red-400/20">
|
||||
Expired
|
||||
</div>
|
||||
<% } else {%>
|
||||
<% if(voucher.used > 0) { %>
|
||||
<div class="rounded-full flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-yellow-50 text-yellow-800 ring-yellow-600/20 dark:text-yellow-400 dark:bg-yellow-400/10 dark:ring-yellow-400/20">
|
||||
In Use
|
||||
@@ -145,11 +165,17 @@
|
||||
Available
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if (voucher.note) { %>
|
||||
<div class="hidden sm:block rounded-md flex-none py-1 px-2 text-xs font-medium ring-1 ring-inset bg-gray-50 text-gray-800 ring-gray-600/20 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20">
|
||||
<%= voucher.note %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-x-2.5 text-xs leading-5 text-gray-600 dark:text-gray-400">
|
||||
<p class="whitespace-nowrap"><%= voucher.quota === 0 ? 'Multi-use' : 'Single-use' %></p>
|
||||
<p class="whitespace-nowrap"><%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %></p>
|
||||
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 flex-none fill-gray-500 dark:fill-gray-300">
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
@@ -214,6 +240,7 @@
|
||||
<div id="detail-dialog"></div>
|
||||
<div id="email-dialog"></div>
|
||||
<div id="print-dialog"></div>
|
||||
<div id="bulk-print-dialog"></div>
|
||||
|
||||
<div id="create-dialog" class="hidden relative z-40" role="dialog" aria-modal="true">
|
||||
<div id="create-dialog-overlay" class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
|
||||
@@ -254,6 +281,12 @@
|
||||
<input type="number" min="1" step="1" value="1" id="voucher-amount" name="voucher-amount" required class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="voucher-note" class="block text-sm font-medium leading-6 text-gray-900 dark:text-white">Note <em class="text-xs">(Optional)</em></label>
|
||||
<div class="mt-2">
|
||||
<input type="text" id="voucher-note" name="voucher-note" class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-white dark:bg-white/5 ring-1 ring-inset ring-gray-300 dark:ring-white/10 focus:ring-2 focus:ring-sky-600 sm:text-sm sm:leading-6">
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-voucher-field hidden border-t border-black/5 dark:border-white/25">
|
||||
<label for="voucher-duration" class="mt-4 block text-sm font-medium leading-6 text-gray-900 dark:text-white">Duration (in Minutes)</label>
|
||||
<div class="mt-2">
|
||||
@@ -387,6 +420,7 @@
|
||||
const detailDialog = document.querySelector('#detail-dialog');
|
||||
const emailDialog = document.querySelector('#email-dialog');
|
||||
const printDialog = document.querySelector('#print-dialog');
|
||||
const bulkPrintDialog = document.querySelector('#bulk-print-dialog');
|
||||
const createForm = document.querySelector('#voucher-form');
|
||||
const voucherTypeField = document.querySelector('#voucher-type');
|
||||
const customVoucherFields = document.querySelectorAll('.custom-voucher-field');
|
||||
@@ -404,6 +438,7 @@
|
||||
const vouchers = document.querySelectorAll('.voucher');
|
||||
const vouchersEmail = document.querySelectorAll('.voucher-email');
|
||||
const vouchersPrint = document.querySelectorAll('.voucher-print');
|
||||
const bulkPrint = document.querySelector('#bulk-print');
|
||||
const filterSortForm = document.querySelector('#filter-sort-form');
|
||||
const statusFilter = document.querySelector('#status');
|
||||
const quotaFilter = document.querySelector('#quota');
|
||||
@@ -428,6 +463,12 @@
|
||||
printDialog.innerHTML = '';
|
||||
};
|
||||
|
||||
const clearBulkPrintDialog = () => {
|
||||
document.querySelector('#close').removeEventListener('click', clearBulkPrintDialog);
|
||||
document.querySelector('#overlay').removeEventListener('click', clearBulkPrintDialog);
|
||||
bulkPrintDialog.innerHTML = '';
|
||||
};
|
||||
|
||||
const emailSpinner = () => {
|
||||
spinnerEmail.style.display = '';
|
||||
};
|
||||
@@ -522,6 +563,17 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
bulkPrint.addEventListener('click', async () => {
|
||||
const htmlRes = await fetch(`<%= baseUrl %>/bulk/print`);
|
||||
|
||||
if(htmlRes.status === 200 && !htmlRes.redirected) {
|
||||
bulkPrintDialog.innerHTML = await htmlRes.text();
|
||||
document.querySelector('#close').addEventListener('click', clearBulkPrintDialog);
|
||||
document.querySelector('#overlay').addEventListener('click', clearBulkPrintDialog);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
statusFilter.addEventListener('change', () => {
|
||||
filterSortForm.submit();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user