This commit is contained in:
+1
-3
@@ -1,12 +1,10 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
loot-hunt:
|
loot-hunt:
|
||||||
image: reg.dev.nervesocket.com/loot-hunt:latest
|
image: reg.dev.nervesocket.com/loot-hunt:latest
|
||||||
container_name: loot-hunt
|
container_name: loot-hunt
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "6233:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
|||||||
+100
-5
@@ -490,28 +490,123 @@ tr:hover {
|
|||||||
/* ─── Responsive ──────────────────────────────────────── */
|
/* ─── Responsive ──────────────────────────────────────── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.navbar {
|
.navbar {
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.5rem;
|
||||||
|
height: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.navbar-brand {
|
||||||
padding: 1rem;
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav a {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.container-narrow {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 1.5rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 1.8rem;
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
flex-direction: column;
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box .value {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box .label {
|
||||||
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-grid {
|
.package-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card .card-num {
|
||||||
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hunt-card {
|
.hunt-card {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-result h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-result .emoji {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-badge.large {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-hero .card-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ const Packages = {
|
|||||||
`).get(shortName, uniqueCode);
|
`).get(shortName, uniqueCode);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
findByHuntAndCardNumber(shortName, cardNumber) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date, h.package_count
|
||||||
|
FROM packages p
|
||||||
|
JOIN hunts h ON p.hunt_id = h.id
|
||||||
|
WHERE h.short_name = ? COLLATE NOCASE AND p.card_number = ?
|
||||||
|
`).get(shortName, parseInt(cardNumber, 10));
|
||||||
|
},
|
||||||
|
|
||||||
getByHunt(huntId) {
|
getByHunt(huntId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT p.*,
|
SELECT p.*,
|
||||||
|
|||||||
@@ -32,6 +32,33 @@ router.get('/leaderboard', (req, res) => {
|
|||||||
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
|
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Package profile (by card number — no secret code exposed) ────
|
||||||
|
router.get('/hunt/:shortName/:cardNumber', (req, res) => {
|
||||||
|
const { shortName, cardNumber } = req.params;
|
||||||
|
if (!/^\d+$/.test(cardNumber)) {
|
||||||
|
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = Packages.findByHuntAndCardNumber(shortName, cardNumber);
|
||||||
|
if (!pkg) {
|
||||||
|
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPkg = Packages.getProfile(pkg.id);
|
||||||
|
const scanHistory = Packages.getScanHistory(pkg.id);
|
||||||
|
const isFirstScanner = req.session && req.session.userId && fullPkg.first_scanned_by === req.session.userId;
|
||||||
|
const isLastScanner = req.session && req.session.userId && fullPkg.last_scanned_by === req.session.userId;
|
||||||
|
|
||||||
|
res.render('loot/profile', {
|
||||||
|
title: `Package ${fullPkg.card_number} of ${pkg.package_count} - ${fullPkg.hunt_name}`,
|
||||||
|
pkg: fullPkg,
|
||||||
|
scanHistory,
|
||||||
|
isFirstScanner,
|
||||||
|
isLastScanner,
|
||||||
|
packages_total: pkg.package_count
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Browse all hunts ─────────────────────────────────────
|
// ─── Browse all hunts ─────────────────────────────────────
|
||||||
router.get('/hunts', (req, res) => {
|
router.get('/hunts', (req, res) => {
|
||||||
const hunts = Hunts.getAll();
|
const hunts = Hunts.getAll();
|
||||||
|
|||||||
+10
-21
@@ -61,40 +61,29 @@ router.get('/:shortName/:code', (req, res) => {
|
|||||||
// Reload package with full profile
|
// Reload package with full profile
|
||||||
const fullPkg = Packages.getProfile(pkg.id);
|
const fullPkg = Packages.getProfile(pkg.id);
|
||||||
const scanHistory = Packages.getScanHistory(pkg.id);
|
const scanHistory = Packages.getScanHistory(pkg.id);
|
||||||
|
const hunt = Hunts.findById(fullPkg.hunt_id);
|
||||||
const isFirstScanner = fullPkg.first_scanned_by === req.session.userId;
|
const isFirstScanner = fullPkg.first_scanned_by === req.session.userId;
|
||||||
const isLastScanner = fullPkg.last_scanned_by === req.session.userId;
|
const isLastScanner = fullPkg.last_scanned_by === req.session.userId;
|
||||||
|
|
||||||
res.render('loot/scanned', {
|
res.render('loot/scanned', {
|
||||||
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
|
title: `Package ${fullPkg.card_number} of ${hunt.package_count} - ${fullPkg.hunt_name}`,
|
||||||
pkg: fullPkg,
|
pkg: fullPkg,
|
||||||
scanResult: result,
|
scanResult: result,
|
||||||
scanHistory,
|
scanHistory,
|
||||||
isFirstScanner,
|
isFirstScanner,
|
||||||
isLastScanner
|
isLastScanner,
|
||||||
|
packages_total: hunt.package_count
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── View package profile (non-scan view) ─────────────────
|
// ─── Redirect old profile URLs to new route ──────────────
|
||||||
router.get('/:shortName/:code/profile', (req, res) => {
|
router.get('/:shortName/:code/profile', (req, res) => {
|
||||||
const { shortName, code } = req.params;
|
const { shortName, code } = req.params;
|
||||||
const pkg = Packages.findByHuntAndCode(shortName, code);
|
const pkg = Packages.findByHuntAndCode(shortName, code);
|
||||||
|
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
return res.status(404).render('error', { title: 'Not Found', message: 'This loot package was not found.' });
|
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||||
}
|
}
|
||||||
|
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||||
const fullPkg = Packages.getProfile(pkg.id);
|
|
||||||
const scanHistory = Packages.getScanHistory(pkg.id);
|
|
||||||
const isFirstScanner = req.session.userId && fullPkg.first_scanned_by === req.session.userId;
|
|
||||||
const isLastScanner = req.session.userId && fullPkg.last_scanned_by === req.session.userId;
|
|
||||||
|
|
||||||
res.render('loot/profile', {
|
|
||||||
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
|
|
||||||
pkg: fullPkg,
|
|
||||||
scanHistory,
|
|
||||||
isFirstScanner,
|
|
||||||
isLastScanner
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Upload first-scan image ──────────────────────────────
|
// ─── Upload first-scan image ──────────────────────────────
|
||||||
@@ -111,12 +100,12 @@ router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.redirect(`/loot/${shortName}/${code}/profile`);
|
return res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagePath = `/uploads/${req.file.filename}`;
|
const imagePath = `/uploads/${req.file.filename}`;
|
||||||
Packages.updateFirstScanImage(pkg.id, imagePath);
|
Packages.updateFirstScanImage(pkg.id, imagePath);
|
||||||
res.redirect(`/loot/${shortName}/${code}/profile`);
|
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Update hint/message ──────────────────────────────────
|
// ─── Update hint/message ──────────────────────────────────
|
||||||
@@ -134,7 +123,7 @@ router.post('/:shortName/:code/hint', requireAuth, (req, res) => {
|
|||||||
|
|
||||||
const hint = (req.body.hint || '').trim().substring(0, 500);
|
const hint = (req.body.hint || '').trim().substring(0, 500);
|
||||||
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
||||||
res.redirect(`/loot/${shortName}/${code}/profile`);
|
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+95
-54
@@ -3,75 +3,116 @@ const QRCode = require('qrcode');
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a printable PDF with QR codes for all packages in a hunt.
|
* Generate a printable PDF with QR codes for all packages in a hunt.
|
||||||
* Layout: 3 cards per page, each card has QR on left and info on right.
|
* Layout: Avery 5371 business card — 10 cards per page (2 columns × 5 rows).
|
||||||
|
* Double-sided: front has QR + info, back has centred monospace message.
|
||||||
|
* All text on cards uses Courier (monospace).
|
||||||
*/
|
*/
|
||||||
async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
||||||
|
// Avery 5371 dimensions on US Letter (all in points, 72pt = 1in)
|
||||||
|
const pageW = 612; // 8.5 in
|
||||||
|
const pageH = 792; // 11 in
|
||||||
|
const cardW = 252; // 3.5 in
|
||||||
|
const cardH = 144; // 2 in
|
||||||
|
const cols = 2;
|
||||||
|
const rows = 5;
|
||||||
|
const cardsPerPage = cols * rows;
|
||||||
|
|
||||||
|
// Centering the grid on the page
|
||||||
|
const gridW = cols * cardW; // 504
|
||||||
|
const gridH = rows * cardH; // 720
|
||||||
|
const offsetX = (pageW - gridW) / 2; // 54
|
||||||
|
const offsetY = (pageH - gridH) / 2; // 36
|
||||||
|
|
||||||
|
const qrSize = 108; // fits nicely in 2 in height with padding
|
||||||
|
const totalPackages = packages.length;
|
||||||
|
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: 'LETTER',
|
size: 'LETTER',
|
||||||
margins: { top: 36, bottom: 36, left: 36, right: 36 }
|
margins: { top: 0, bottom: 0, left: 0, right: 0 }
|
||||||
});
|
});
|
||||||
|
|
||||||
doc.pipe(outputStream);
|
doc.pipe(outputStream);
|
||||||
|
|
||||||
const pageWidth = 612 - 72; // letter width minus margins
|
// Process cards in chunks of cardsPerPage
|
||||||
const cardHeight = 200;
|
const pageCount = Math.ceil(packages.length / cardsPerPage);
|
||||||
const qrSize = 160;
|
|
||||||
const cardsPerPage = 3;
|
|
||||||
|
|
||||||
for (let i = 0; i < packages.length; i++) {
|
for (let p = 0; p < pageCount; p++) {
|
||||||
const pkg = packages[i];
|
const startIdx = p * cardsPerPage;
|
||||||
const cardIndex = i % cardsPerPage;
|
const pagePackages = packages.slice(startIdx, startIdx + cardsPerPage);
|
||||||
|
|
||||||
if (i > 0 && cardIndex === 0) {
|
// ──── FRONT PAGE ────
|
||||||
doc.addPage();
|
if (p > 0) doc.addPage();
|
||||||
|
|
||||||
|
for (let c = 0; c < pagePackages.length; c++) {
|
||||||
|
const pkg = pagePackages[c];
|
||||||
|
const col = c % cols;
|
||||||
|
const row = Math.floor(c / cols);
|
||||||
|
const x = offsetX + col * cardW;
|
||||||
|
const y = offsetY + row * cardH;
|
||||||
|
|
||||||
|
// QR code
|
||||||
|
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`;
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(url, {
|
||||||
|
width: qrSize * 2,
|
||||||
|
margin: 1,
|
||||||
|
errorCorrectionLevel: 'M'
|
||||||
|
});
|
||||||
|
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
|
||||||
|
|
||||||
|
const qrX = x + 10;
|
||||||
|
const qrY = y + (cardH - qrSize) / 2;
|
||||||
|
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize });
|
||||||
|
|
||||||
|
// Text column (monospace)
|
||||||
|
const textX = x + qrSize + 20;
|
||||||
|
const textW = cardW - qrSize - 30;
|
||||||
|
const textBlockH = 64;
|
||||||
|
const textY = y + (cardH - textBlockH) / 2;
|
||||||
|
|
||||||
|
doc.fontSize(18).font('Courier-Bold').fillColor('#1a1a1a');
|
||||||
|
doc.text(`${pkg.card_number} of ${totalPackages}`, textX, textY, { width: textW, align: 'center' });
|
||||||
|
|
||||||
|
doc.fontSize(12).font('Courier').fillColor('#666666');
|
||||||
|
doc.text(hunt.short_name, textX, textY + 24, { width: textW, align: 'center' });
|
||||||
|
|
||||||
|
doc.fontSize(14).font('Courier-Bold').fillColor('#333333');
|
||||||
|
doc.text(pkg.unique_code, textX, textY + 44, { width: textW, align: 'center' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const yOffset = 36 + cardIndex * (cardHeight + 24);
|
// ──── BACK PAGE (mirrored column order for double-sided printing) ────
|
||||||
|
doc.addPage();
|
||||||
|
|
||||||
// Draw card border
|
const backLines = [
|
||||||
doc.save();
|
'THIS CARD IS UNIQUE',
|
||||||
doc.roundedRect(36, yOffset, pageWidth, cardHeight, 8).stroke('#cccccc');
|
'',
|
||||||
doc.restore();
|
'IF YOU DO NOT USE IT',
|
||||||
|
'PLEASE PUT IT SOMEWHERE',
|
||||||
|
'INTERESTING FOR THE',
|
||||||
|
'NEXT PERSON TO FIND',
|
||||||
|
'',
|
||||||
|
'PLEASE DO NOT',
|
||||||
|
'THROW ME AWAY'
|
||||||
|
];
|
||||||
|
const lineHeight = 11;
|
||||||
|
const blockH = backLines.length * lineHeight;
|
||||||
|
|
||||||
// Generate QR code as data URL
|
for (let c = 0; c < pagePackages.length; c++) {
|
||||||
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`;
|
// Mirror columns: col 0 ↔ col 1 so backs align when flipped
|
||||||
const qrDataUrl = await QRCode.toDataURL(url, {
|
const col = c % cols;
|
||||||
width: qrSize,
|
const mirroredCol = cols - 1 - col;
|
||||||
margin: 1,
|
const row = Math.floor(c / cols);
|
||||||
errorCorrectionLevel: 'M'
|
const x = offsetX + mirroredCol * cardW;
|
||||||
});
|
const y = offsetY + row * cardH;
|
||||||
|
|
||||||
// Convert data URL to buffer for PDFKit
|
const startY = y + (cardH - blockH) / 2;
|
||||||
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
|
|
||||||
|
|
||||||
// QR code on the left
|
doc.font('Courier-Bold').fontSize(9).fillColor('#333333');
|
||||||
const qrX = 48;
|
backLines.forEach((line, li) => {
|
||||||
const qrY = yOffset + (cardHeight - qrSize) / 2;
|
doc.text(line, x, startY + li * lineHeight, {
|
||||||
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize });
|
width: cardW,
|
||||||
|
align: 'center'
|
||||||
// Info on the right
|
});
|
||||||
const textX = 48 + qrSize + 24;
|
});
|
||||||
const textY = yOffset + 20;
|
}
|
||||||
|
|
||||||
// Card number
|
|
||||||
doc.fontSize(28).font('Helvetica-Bold').fillColor('#1a1a1a');
|
|
||||||
doc.text(`#${pkg.card_number}`, textX, textY, { width: pageWidth - qrSize - 60 });
|
|
||||||
|
|
||||||
// Hunt short name
|
|
||||||
doc.fontSize(16).font('Helvetica').fillColor('#666666');
|
|
||||||
doc.text(hunt.short_name, textX, textY + 38, { width: pageWidth - qrSize - 60 });
|
|
||||||
|
|
||||||
// Unique code
|
|
||||||
doc.fontSize(22).font('Helvetica-Bold').fillColor('#333333');
|
|
||||||
doc.text(pkg.unique_code, textX, textY + 62, { width: pageWidth - qrSize - 60 });
|
|
||||||
|
|
||||||
// Dashed line separator
|
|
||||||
doc.fontSize(9).font('Helvetica').fillColor('#999999');
|
|
||||||
doc.text(url, textX, textY + 100, { width: pageWidth - qrSize - 60 });
|
|
||||||
|
|
||||||
// Hunt name at bottom of card
|
|
||||||
doc.fontSize(11).font('Helvetica').fillColor('#444444');
|
|
||||||
doc.text(hunt.name, textX, textY + 128, { width: pageWidth - qrSize - 60 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<td><%= pkg.first_scanner_name || '—' %></td>
|
<td><%= pkg.first_scanner_name || '—' %></td>
|
||||||
<td><%= pkg.last_scanner_name || '—' %></td>
|
<td><%= pkg.last_scanner_name || '—' %></td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-sm btn-outline">View</a>
|
<a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -40,10 +40,9 @@
|
|||||||
|
|
||||||
<div class="package-grid">
|
<div class="package-grid">
|
||||||
<% packages.forEach(pkg => { %>
|
<% packages.forEach(pkg => { %>
|
||||||
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile"
|
<a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>"
|
||||||
class="package-card <%= pkg.scan_count > 0 ? 'scanned' : 'unscanned' %>">
|
class="package-card <%= pkg.scan_count > 0 ? 'scanned' : 'unscanned' %>">
|
||||||
<div class="card-num">#<%= pkg.card_number %></div>
|
<div class="card-num"><%= pkg.card_number %> of <%= hunt.package_count %></div>
|
||||||
<div class="code"><%= pkg.unique_code %></div>
|
|
||||||
<div class="scan-info">
|
<div class="scan-info">
|
||||||
<% if (pkg.scan_count > 0) { %>
|
<% if (pkg.scan_count > 0) { %>
|
||||||
✅ Found · <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
|
✅ Found · <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
|
||||||
|
|||||||
@@ -7,9 +7,8 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="package-hero">
|
<div class="package-hero">
|
||||||
<div class="card-number">#<%= pkg.card_number %></div>
|
<div class="card-number"><%= pkg.card_number %> of <%= packages_total || '' %></div>
|
||||||
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
|
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
|
||||||
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
@@ -18,11 +17,11 @@
|
|||||||
<div class="label">Total Scans</div>
|
<div class="label">Total Scans</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="value"><%= pkg.first_scanner_name || '—' %></div>
|
<div class="value"><%= pkg.first_scanner_name || '---' %></div>
|
||||||
<div class="label">First Finder</div>
|
<div class="label">First Finder</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="value"><%= pkg.last_scanner_name || '—' %></div>
|
<div class="value"><%= pkg.last_scanner_name || '---' %></div>
|
||||||
<div class="label">Most Recent</div>
|
<div class="label">Most Recent</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +97,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 1rem;">
|
<div style="text-align: center; margin-top: 1rem;">
|
||||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">View All Packages</a>
|
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">All Packages</a>
|
||||||
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,9 +21,8 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="package-hero">
|
<div class="package-hero">
|
||||||
<div class="card-number">#<%= pkg.card_number %></div>
|
<div class="card-number"><%= pkg.card_number %> of <%= packages_total || '' %></div>
|
||||||
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
|
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
|
||||||
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
@@ -32,11 +31,11 @@
|
|||||||
<div class="label">Total Scans</div>
|
<div class="label">Total Scans</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="value"><%= pkg.first_scanner_name || '—' %></div>
|
<div class="value"><%= pkg.first_scanner_name || '---' %></div>
|
||||||
<div class="label">First Finder</div>
|
<div class="label">First Finder</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-box">
|
<div class="stat-box">
|
||||||
<div class="value"><%= pkg.last_scanner_name || '—' %></div>
|
<div class="value"><%= pkg.last_scanner_name || '---' %></div>
|
||||||
<div class="label">Most Recent</div>
|
<div class="label">Most Recent</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +113,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 1rem;">
|
<div style="text-align: center; margin-top: 1rem;">
|
||||||
<a href="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-outline">View Package Profile</a>
|
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>" class="btn btn-outline">Package Profile</a>
|
||||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">Back to Hunt</a>
|
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">Back to Hunt</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user