add supporters section to homepage

Shows paying squadrons as pill links (SHORT // LONG → /squadrons/<short>) above the footer, with a 15-min server-side cache backed by entitlements DB + SQUADRONS.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
deploy
2026-05-27 12:38:58 +00:00
parent f042fd4d8a
commit 95f38a9b0d
3 changed files with 65 additions and 2 deletions
+2 -1
View File
@@ -102,7 +102,8 @@
"ctaReign": "Ready to R3IGN again?",
"ctaMeow": "Meowww",
"ctaPurr": "Purrr",
"ctaRawr": "Rawr"
"ctaRawr": "Rawr",
"supportedBy": "Supported by"
},
"docs": {
"title": "Documentation",
+46 -1
View File
@@ -98,6 +98,7 @@ fs.mkdirSync(REPLAYS_ROOT, { recursive: true });
const LEGACY_REPLAYS_ROOT = path.join(__dirname, '..', 'replays');
const ENTITLEMENTS_DB_PATH = path.join(STORAGE_ROOT, 'entitlements.db');
const SQUADRONS_DB_PATH = path.join(STORAGE_ROOT, 'squadrons.db');
const SQUADRONS_JSON_PATH = path.join(STORAGE_ROOT, 'SQUADRONS.json');
const entitlementsDb = new sqlite3.Database(ENTITLEMENTS_DB_PATH);
const squadronsDb = fs.existsSync(SQUADRONS_DB_PATH)
? new sqlite3.Database(SQUADRONS_DB_PATH, sqlite3.OPEN_READONLY)
@@ -824,12 +825,56 @@ app.use((req, res, next) => {
next();
});
// ── Supporters (paying squadrons) ────────────────────────────────────────────
let _supportersCache = null;
let _supportersCacheAt = 0;
const SUPPORTERS_TTL = 15 * 60 * 1000;
function getSupporters() {
if (_supportersCache && Date.now() - _supportersCacheAt < SUPPORTERS_TTL) {
return Promise.resolve(_supportersCache);
}
return new Promise((resolve) => {
let squads;
try {
squads = JSON.parse(fs.readFileSync(SQUADRONS_JSON_PATH, 'utf8'));
} catch {
return resolve([]);
}
entitlementsDb.all(
`SELECT guild_id FROM guild_entitlements WHERE status='active'
UNION SELECT guild_id FROM discord_entitlements
UNION SELECT guild_id FROM manual_entitlements WHERE expires_at > strftime('%s','now')`,
[],
(err, rows) => {
if (err) return resolve([]);
const paying = new Set(rows.map(r => r.guild_id));
const seen = new Map();
for (const [gid, sq] of Object.entries(squads)) {
if (paying.has(gid) && !seen.has(sq.SQ_ShortHand_Name)) {
seen.set(sq.SQ_ShortHand_Name, sq.SQ_LongHandName);
}
}
const result = [...seen.entries()]
.map(([short, long]) => ({ short, long }))
.sort((a, b) => a.long.localeCompare(b.long));
_supportersCache = result;
_supportersCacheAt = Date.now();
resolve(result);
}
);
});
}
// Routes
app.get('/', (req, res) => {
app.get('/', async (req, res) => {
const siteUrl = (process.env.PRODUCTION_DOMAIN || 'https://sre.pawjob.us').replace(/\/$/, '');
const supporters = await getSupporters().catch(() => []);
res.render('index', {
botName: 'Toothless SQB Bot',
supporters,
metaTitle: "Toothless' SRE Bot",
metaDescription: 'The Best Squadron Battles Bot.',
metaUrl: `${siteUrl}/`,
+17
View File
@@ -366,6 +366,23 @@
</div>
</section>
<!-- Supporters -->
<% if (typeof supporters !== 'undefined' && supporters.length > 0) { %>
<section class="py-10 border-t border-[rgba(144,238,144,0.06)]">
<div class="max-w-[1400px] mx-auto px-6 lg:px-8">
<p class="text-center text-xs uppercase tracking-widest text-muted opacity-50 mb-5"><%= t('home.supportedBy') %></p>
<div class="flex flex-wrap justify-center gap-2">
<% supporters.forEach(function(sq) { %>
<a href="/squadrons/<%= encodeURIComponent(sq.short) %>"
class="px-3 py-1 rounded-full text-[11px] font-medium border border-[rgba(144,238,144,0.15)] text-muted hover:text-accent hover:border-[rgba(144,238,144,0.35)] transition-colors">
<span class="opacity-60"><%= sq.short %></span> // <%= sq.long %>
</a>
<% }); %>
</div>
</div>
</section>
<% } %>
<!-- Footer -->
<%- include('partials/footer') %>