diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
index 1e283a9..0602dcb 100644
--- a/apps/web/components/Footer.tsx
+++ b/apps/web/components/Footer.tsx
@@ -31,6 +31,7 @@ export default function Footer() {
Browse
Home
All Stores
+ Forums
Search
Submit a Code
Bounties
diff --git a/apps/web/components/Header.tsx b/apps/web/components/Header.tsx
index 559deee..a50ec5e 100644
--- a/apps/web/components/Header.tsx
+++ b/apps/web/components/Header.tsx
@@ -26,6 +26,10 @@ export default function Header() {
Stores
+ {/* A plain : /bbs is the tsbb board nginx serves, not a Next route. */}
+
+ Forums
+
Bounties
@@ -75,6 +79,9 @@ export default function Header() {
setMenuOpen(false)}>
Stores
+
+ Forums
+
setMenuOpen(false)}>
Bounties
diff --git a/ops/bbs/README.md b/ops/bbs/README.md
new file mode 100644
index 0000000..09673a9
--- /dev/null
+++ b/ops/bbs/README.md
@@ -0,0 +1,23 @@
+# c0upons.com/bbs
+
+The forums at https://c0upons.com/bbs are a [tsbb](https://github.com/profullstack/tsbb)
+board, not part of this app. nginx sends `/bbs` and `/bbs/*` to it before the Next.js
+app ever sees the request (cli-tools `dev2/sites.d/c0upons.com.json`, `nginx_locations`).
+
+| | |
+| --- | --- |
+| Box | dev2, `/home/anthony/www/c0upons.com-bbs`, container `c0uponscom-bbs-app-1`, `127.0.0.1:3293` |
+| Base URL | `TSBB_BASE_URL=https://c0upons.com/bbs` (tsbb >= 0.9.0 mounts itself under the path) |
+| Data | SQLite on the volume: `volumes/data/tsbb.db` |
+| Secrets | logicsrc vault `c0upons-bbs--prod` |
+| Updates | follows tsbb GitHub releases on its own (`TSBB_CHECKOUT_DIR`), every 5 minutes |
+| Admin | `anthony` (anthony@profullstack.com), sign in at /bbs/login |
+
+`seed-forums.ts` creates the Slickdeals-style forum tree and the c0upons branding.
+It is idempotent: forums are matched by slug, so re-running only adds what is missing.
+
+```sh
+docker cp ops/bbs/seed-forums.ts c0uponscom-bbs-app-1:/app/data/seed-forums.ts
+docker exec c0uponscom-bbs-app-1 node /app/data/seed-forums.ts
+docker restart c0uponscom-bbs-app-1 # settings are cached per process
+```
diff --git a/ops/bbs/seed-forums.ts b/ops/bbs/seed-forums.ts
new file mode 100644
index 0000000..55812df
--- /dev/null
+++ b/ops/bbs/seed-forums.ts
@@ -0,0 +1,104 @@
+/**
+ * The c0upons.com/bbs forum tree and branding: a Slickdeals-style deal board.
+ *
+ * Runs inside the tsbb container, against the board's own database:
+ *
+ * docker cp ops/bbs/seed-forums.ts c0uponscom-bbs-app-1:/app/data/seed-forums.ts
+ * docker exec c0uponscom-bbs-app-1 node /app/data/seed-forums.ts
+ * docker restart c0uponscom-bbs-app-1 # settings are cached per process
+ *
+ * Idempotent. Forums are matched by slug, so re-running adds what is missing
+ * and leaves admin edits (names, order) alone. It runs before tsbb's own seed,
+ * which then skips its generic starter tree because forums already exist, and
+ * fills in groups, permissions and ranks as usual.
+ */
+const CHECKOUT = process.env.TSBB_CHECKOUT_DIR ?? '/app';
+const { migrate } = await import(`${CHECKOUT}/packages/db/src/migrate.ts`);
+const { one, run, now } = await import(`${CHECKOUT}/packages/db/src/client.ts`);
+const { seed } = await import(`${CHECKOUT}/packages/db/src/seed.ts`);
+
+type Forum = { slug: string; name: string; description: string };
+type Category = { slug: string; name: string; forums: Forum[] };
+
+const TREE: Category[] = [
+ {
+ slug: 'deals',
+ name: 'Deals',
+ forums: [
+ { slug: 'hot-deals', name: 'Hot Deals', description: 'Found a price worth sharing? Post the store, the price and the link. The best ones get voted up.' },
+ { slug: 'coupons', name: 'Coupons & Promo Codes', description: 'Working codes, printable coupons, cashback and stacking tricks. Say where and when it worked.' },
+ { slug: 'freebies', name: 'Freebies', description: 'Free samples, free-after-rebate, free trials actually worth having.' },
+ { slug: 'grocery', name: 'Grocery & Weekly Ads', description: 'Weekly flyer highlights, digital coupons and grocery stacks.' },
+ { slug: 'tech-deals', name: 'Tech Deals', description: 'Laptops, phones, games, gadgets and software.' },
+ { slug: 'home-deals', name: 'Home & Garden', description: 'Appliances, tools, furniture and everything for the house.' },
+ { slug: 'fashion-deals', name: 'Clothing & Beauty', description: 'Apparel, shoes, accessories and beauty sales.' },
+ { slug: 'travel-deals', name: 'Travel Deals', description: 'Flights, hotels, error fares and points tricks.' },
+ ],
+ },
+ {
+ slug: 'community',
+ name: 'Community',
+ forums: [
+ { slug: 'deal-requests', name: 'Deal Requests', description: 'Looking for something? Ask, and someone will hunt down the best price.' },
+ { slug: 'deal-talk', name: 'Deal Talk', description: 'Price history, store policies, price matching, return wins and losses.' },
+ { slug: 'contests', name: 'Contests & Giveaways', description: 'Sweepstakes and giveaways worth entering.' },
+ { slug: 'off-topic', name: 'Off Topic', description: 'Anything that is not a deal.' },
+ ],
+ },
+ {
+ slug: 'c0upons',
+ name: 'c0upons',
+ forums: [
+ { slug: 'announcements', name: 'Announcements', description: 'News about c0upons.com and these forums.' },
+ { slug: 'feedback', name: 'Feedback & Bugs', description: 'Something broken, or an idea for the site? Tell us here.' },
+ ],
+ },
+];
+
+const SETTINGS: Record = {
+ 'board.name': 'c0upons forums',
+ 'board.tagline': 'Hot deals, coupon codes and freebies, shared by the people who found them.',
+ 'board.description':
+ 'The c0upons.com community: post hot deals, working promo codes and freebies, ask for a deal, and vote up the best finds.',
+ 'board.skin': 'classic',
+ 'board.accent': '#f97316',
+ 'board.logoUrl': 'https://c0upons.com/logo.svg',
+ 'board.logoHref': 'https://c0upons.com',
+ 'board.faviconUrl': 'https://c0upons.com/favicon.svg',
+ // The tsbb platform pitch on the index is for boards that are about tsbb.
+ 'board.showPlatform': false,
+};
+
+await migrate(undefined, { quiet: true });
+
+async function upsertForum(parentId: number | null, kind: string, slug: string, name: string, description: string | null, position: number) {
+ const existing = await one<{ id: number }>('SELECT id FROM forums WHERE slug = ?', [slug]);
+ if (existing) return Number(existing.id);
+ const result = await run(
+ `INSERT INTO forums (parent_id, kind, slug, name, description, position, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
+ [parentId, kind, slug, name, description, position, now()],
+ );
+ console.log(` + ${kind} ${slug}`);
+ return Number((result.rows[0] as unknown as { id: number }).id);
+}
+
+for (const [c, category] of TREE.entries()) {
+ const parent = await upsertForum(null, 'category', category.slug, category.name, null, c);
+ for (const [f, forum] of category.forums.entries()) {
+ await upsertForum(parent, 'forum', forum.slug, forum.name, forum.description, f);
+ }
+}
+
+for (const [key, value] of Object.entries(SETTINGS)) {
+ await run(
+ `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
+ [key, JSON.stringify(value), now()],
+ );
+}
+console.log(' settings');
+
+await seed({ quiet: true });
+console.log('Done. Restart the container so the running board reloads its settings.');
+process.exit(0);