Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function Footer() {
<span className="font-semibold text-gray-200 text-xs uppercase tracking-widest">Browse</span>
<Link href="/" className="hover:text-white transition-colors">Home</Link>
<Link href="/stores" className="hover:text-white transition-colors">All Stores</Link>
<a href="/bbs" className="hover:text-white transition-colors">Forums</a>
<Link href="/search" className="hover:text-white transition-colors">Search</Link>
<Link href="/submit" className="hover:text-white transition-colors">Submit a Code</Link>
<Link href="/bounties" className="hover:text-white transition-colors">Bounties</Link>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default function Header() {
<Link href="/stores" className="px-3 py-1.5 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors">
Stores
</Link>
{/* A plain <a>: /bbs is the tsbb board nginx serves, not a Next route. */}
<a href="/bbs" className="px-3 py-1.5 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors">
Forums
</a>
<Link href="/bounties" className="px-3 py-1.5 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors">
Bounties
</Link>
Expand Down Expand Up @@ -75,6 +79,9 @@ export default function Header() {
<Link href="/stores" className="px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => setMenuOpen(false)}>
Stores
</Link>
<a href="/bbs" className="px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
Forums
</a>
<Link href="/bounties" className="px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => setMenuOpen(false)}>
Bounties
</Link>
Expand Down
23 changes: 23 additions & 0 deletions ops/bbs/README.md
Original file line number Diff line number Diff line change
@@ -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
```
104 changes: 104 additions & 0 deletions ops/bbs/seed-forums.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
'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);
Loading