Compare commits

...

2 Commits

Author SHA1 Message Date
bbe
78271ea110 feat: organizers, meetups UI, Plausible analytics, and migration tooling
- Add organizer model/API, admin and public organizer pages, meetup cards
- Refresh events/home/contact; add calendar dialog and carousel components
- Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout
- Prisma migration, seed updates, baseline-and-migrate script

Made-with: Cursor
2026-04-04 21:55:34 +02:00
bbe
586b572f73 feat(board): Lightning-paid message board with LNbits and admin moderation
Add public /board flow: create invoice, webhook + confirm reconciliation, list
active messages, likes (Nostr), zap fallbacks. Admin table for hide/delete.

Include LNbits webhook body normalization (double-encoded JSON), POST
/api/messages/confirm/:hash, and root npm db:push script. Prisma models for
pending invoices and board messages.

Made-with: Cursor
2026-04-03 18:37:52 +02:00
45 changed files with 2809 additions and 303 deletions

View File

@@ -4,7 +4,12 @@ ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# Nostr relays (comma-separated)
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
# Database (use an absolute `file:` URL in production, e.g. file:/home/bbe/BelgianBitcoinEmbassy/backend/prisma/prod.db)
# Database (path is relative to the backend working directory for file: URLs — keep deploy cwd consistent)
# After pulling code: from backend run `npm run migrate:deploy` (or `npm run db:migrate`) so migrations
# run in order. Do not rely on `db:push` for upgrades that add required columns to non-empty tables.
#
# If migrate fails with P3005 (schema not empty / no migration history, e.g. DB was created with db push),
# from backend run once: `npm run db:baseline-and-migrate` (see scripts/baseline-and-migrate.sh).
DATABASE_URL="file:./dev.db"
# JWT
@@ -22,3 +27,21 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api
NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org
NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy
NEXT_PUBLIC_SITE_TAGLINE=Belgium's Monthly Bitcoin Meetup
# Plausible analytics (optional; both must be set or the script is omitted)
# Tracked site domain (data-domain). Example: belgianbitcoinembassy.org
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=belgianbitcoinembassy.org
# Plausible / custom analytics host origin, no trailing slash. Example: https://analytics.azzamo.net
NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN=https://analytics.azzamo.net
# Message board (Lightning / LNbits) — backend
MESSAGE_PRICE_SATS=1000
LNBITS_API_KEY=
LNBITS_WEBHOOK_SECRET=
LNBITS_URL=https://legend.lnbits.com
# Public URL that LNbits can POST webhooks to (usually your site origin so /api/messages/webhook hits the API)
WEBHOOK_BASE_URL=http://localhost:3000
# Optional: lnaddress or LNURL-pay string for “Zap BBE” when the message has no pubkey
BOARD_ZAP_LN_ADDRESS=
# Optional: hex pubkey for njump fallback when BOARD_ZAP_LN_ADDRESS is unset
BOARD_ZAP_PUBKEY=

View File

@@ -5,10 +5,12 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:push": "dotenv -e ../.env -- prisma db push",
"db:seed": "dotenv -e ../.env -- prisma db seed",
"db:studio": "dotenv -e ../.env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -- prisma migrate deploy"
"db:push": "dotenv -e ../.env -e .env -- prisma db push",
"db:migrate": "dotenv -e ../.env -e .env -- prisma migrate deploy",
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
},
"prisma": {
"seed": "tsx prisma/seed.ts"

View File

@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "Organizer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX "Organizer_slug_key" ON "Organizer"("slug");
INSERT INTO "Organizer" ("id", "name", "slug", "createdAt", "updatedAt")
VALUES (
'00000000-0000-4000-8000-000000000001',
'Belgian Bitcoin Embassy',
'belgian-bitcoin-embassy',
datetime('now'),
datetime('now')
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Meetup" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"location" TEXT NOT NULL,
"link" TEXT,
"imageId" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"featured" BOOLEAN NOT NULL DEFAULT false,
"visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
"organizerId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Meetup_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "Organizer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "organizerId", "status", "time", "title", "updatedAt", "visibility")
SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", '00000000-0000-4000-8000-000000000001', "status", "time", "title", "updatedAt", "visibility" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
CREATE INDEX "Meetup_organizerId_idx" ON "Meetup"("organizerId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -17,8 +17,17 @@ model User {
updatedAt DateTime @updatedAt
}
model Organizer {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
meetups Meetup[]
}
model Meetup {
id String @id @default(uuid())
id String @id @default(uuid())
title String
description String
date String
@@ -26,11 +35,13 @@ model Meetup {
location String
link String?
imageId String?
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
organizerId String
organizer Organizer @relation(fields: [organizerId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Media {
@@ -147,3 +158,27 @@ model Faq {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// Pending Lightning invoice metadata (message is created only after payment via webhook).
model BoardInvoicePending {
paymentHash String @id
content String
guestName String?
pubkey String?
profilePic String?
createdAt DateTime @default(now())
}
/// Paid board message (LNbits payment confirmed).
model BoardMessage {
id String @id @default(uuid())
paymentHash String @unique
content String
authorName String
pubkey String?
profilePic String?
satsPaid Int
status String @default("active") // active, hidden, deleted
likeCount Int @default(0)
createdAt DateTime @default(now())
}

View File

@@ -56,6 +56,16 @@ async function main() {
});
}
const defaultOrganizer = await prisma.organizer.upsert({
where: { slug: 'belgian-bitcoin-embassy' },
update: {},
create: {
id: '00000000-0000-4000-8000-000000000001',
name: 'Belgian Bitcoin Embassy',
slug: 'belgian-bitcoin-embassy',
},
});
const existingMeetup = await prisma.meetup.findFirst({
where: { title: 'Monthly Bitcoin Meetup' },
});
@@ -70,8 +80,9 @@ async function main() {
time: '19:00',
location: 'Brussels, Belgium',
link: 'https://meetup.com/example',
status: 'UPCOMING',
status: 'PUBLISHED',
featured: true,
organizerId: defaultOrganizer.id,
},
});
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Use when `npm run db:migrate` fails with P3005 (DB has tables but no migrate history, e.g. after db push).
# Marks historical migrations as already applied, then runs `migrate deploy` to apply pending ones (e.g. organizer).
set -euo pipefail
cd "$(dirname "$0")/.."
for migration_name in \
20260331051150_add_user_username \
20260331053518_add_meetup_visibility \
20260331061812_add_user_username
do
echo "Marking as applied: $migration_name"
# Ignore failures when already recorded or already baselined
dotenv -e ../.env -e .env -- prisma migrate resolve --applied "$migration_name" || true
done
echo "Applying any pending migrations..."
dotenv -e ../.env -e .env -- prisma migrate deploy

View File

@@ -0,0 +1,68 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.use(requireAuth, requireRole(['ADMIN', 'MODERATOR']));
router.get('/', async (_req: Request, res: Response) => {
try {
const messages = await prisma.boardMessage.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(messages);
} catch (err) {
console.error('Admin list board messages error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Toggle active <-> hidden */
router.post('/:id/hide', async (req: Request, res: Response) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
const msg = await prisma.boardMessage.findUnique({ where: { id } });
if (!msg) {
res.status(404).json({ error: 'Not found' });
return;
}
if (msg.status === 'deleted') {
res.status(400).json({ error: 'Message is deleted' });
return;
}
const next = msg.status === 'hidden' ? 'active' : 'hidden';
const updated = await prisma.boardMessage.update({
where: { id: msg.id },
data: { status: next },
});
res.json(updated);
} catch (err) {
console.error('Admin hide board message error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Soft-delete */
router.delete('/:id', async (req: Request, res: Response) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
const msg = await prisma.boardMessage.findUnique({ where: { id } });
if (!msg) {
res.status(404).json({ error: 'Not found' });
return;
}
const updated = await prisma.boardMessage.update({
where: { id: msg.id },
data: { status: 'deleted' },
});
res.json(updated);
} catch (err) {
console.error('Admin delete board message error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
@@ -92,6 +93,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
orderBy: { date: 'asc' },
include: { organizer: true },
});
const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace(
@@ -129,8 +131,11 @@ router.get('/ics', async (_req: Request, res: Response) => {
lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`));
}
lines.push(fold(`URL:${eventUrl}`));
const orgName = meetup.organizer?.name || 'Belgian Bitcoin Embassy';
lines.push(
'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org'
fold(
`ORGANIZER;CN=${escapeIcs(orgName)}:mailto:info@belgianbitcoinembassy.org`
)
);
// 15-minute reminder alarm
lines.push('BEGIN:VALARM');
@@ -156,6 +161,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
res.send(icsBody);
} catch (err) {
console.error('Calendar ICS error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});

View File

@@ -1,9 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
const meetupInclude = { organizer: true } as const;
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
@@ -13,22 +17,42 @@ function incrementTitle(title: string): string {
return `${title} (copy)`;
}
async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string | null> {
if (typeof organizerId === 'string' && organizerId.trim()) {
return organizerId;
}
const def = await prisma.organizer.findUnique({ where: { slug: DEFAULT_ORGANIZER_SLUG } });
return def?.id ?? null;
}
router.get('/', async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true';
const where: any = {};
const organizerSlug = req.query.organizerSlug as string | undefined;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (!admin) where.visibility = 'PUBLIC';
if (organizerSlug) {
const org = await prisma.organizer.findUnique({ where: { slug: organizerSlug } });
if (!org) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
where.organizerId = org.id;
}
const meetups = await prisma.meetup.findMany({
where,
orderBy: { date: 'asc' },
include: meetupInclude,
});
res.json(meetups);
} catch (err) {
console.error('List meetups error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -37,6 +61,7 @@ router.get('/:id', async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
include: meetupInclude,
});
if (!meetup) {
@@ -47,6 +72,7 @@ router.get('/:id', async (req: Request, res: Response) => {
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -57,8 +83,19 @@ router.post(
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const {
title,
description,
date,
time,
location,
link,
status,
featured,
imageId,
visibility,
organizerId,
} = req.body;
if (!title || !description || !date || !time || !location) {
res
@@ -67,6 +104,12 @@ router.post(
return;
}
const resolvedOrganizerId = await resolveOrganizerIdForCreate(organizerId);
if (!resolvedOrganizerId) {
res.status(500).json({ error: 'Default organizer is not configured' });
return;
}
const meetup = await prisma.meetup.create({
data: {
title,
@@ -79,7 +122,9 @@ router.post(
status: status || 'DRAFT',
featured: featured || false,
visibility: visibility || 'PUBLIC',
organizerId: resolvedOrganizerId,
},
include: meetupInclude,
});
res.status(201).json(meetup);
@@ -134,7 +179,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
organizerId: m.organizerId,
},
include: meetupInclude,
})
)
);
@@ -174,7 +221,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
organizerId: original.organizerId,
},
include: meetupInclude,
});
res.status(201).json(duplicate);
@@ -199,10 +248,21 @@ router.patch(
return;
}
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const {
title,
description,
date,
time,
location,
link,
status,
featured,
imageId,
visibility,
organizerId,
} = req.body;
const updateData: any = {};
const updateData: Record<string, unknown> = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (date !== undefined) updateData.date = date;
@@ -213,10 +273,12 @@ router.patch(
if (featured !== undefined) updateData.featured = featured;
if (imageId !== undefined) updateData.imageId = imageId;
if (visibility !== undefined) updateData.visibility = visibility;
if (organizerId !== undefined) updateData.organizerId = organizerId;
const updated = await prisma.meetup.update({
where: { id: req.params.id as string },
data: updateData,
include: meetupInclude,
});
res.json(updated);

359
backend/src/api/messages.ts Normal file
View File

@@ -0,0 +1,359 @@
import { Router, Request, Response } from 'express';
import { timingSafeEqual } from 'crypto';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import {
createIncomingInvoice,
verifyIncomingPaymentPaid,
getPublicPaymentStatus,
} from '../services/lnbits';
const router = Router();
const MAX_CONTENT = 300;
const MAX_NAME = 80;
const MESSAGE_PRICE_SATS = Math.max(1, parseInt(process.env.MESSAGE_PRICE_SATS || '1000', 10) || 1000);
const WEBHOOK_BASE = (process.env.WEBHOOK_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3000').replace(
/\/$/,
''
);
const WEBHOOK_SECRET = process.env.LNBITS_WEBHOOK_SECRET || '';
function sanitizeText(s: string, max: number): string {
const t = s
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/<[^>]*>/g, '')
.trim();
return t.length > max ? t.slice(0, max) : t;
}
/** LNbits sends `json=payment.json()` where `.json()` returns a Pydantic JSON
* string — httpx double-encodes it, so Express may parse the body as a plain
* string instead of an object. Unwrap up to two layers of JSON encoding. */
function normalizeWebhookBody(raw: unknown): unknown {
let v = raw;
for (let i = 0; i < 2 && typeof v === 'string'; i++) {
try { v = JSON.parse(v); } catch { break; }
}
return v;
}
const HEX64 = /^[a-f0-9]{64}$/i;
function extractPaymentHash(body: unknown): string | null {
const o = normalizeWebhookBody(body);
if (!o || typeof o !== 'object') return null;
const r = o as Record<string, unknown>;
for (const key of ['payment_hash', 'paymentHash', 'checking_id']) {
const v = r[key];
if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase();
}
const p = r.payment;
if (p && typeof p === 'object') {
const nested = p as Record<string, unknown>;
for (const key of ['payment_hash', 'paymentHash', 'checking_id']) {
const v = nested[key];
if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase();
}
}
return null;
}
function verifyWebhookSecret(req: Request): boolean {
if (!WEBHOOK_SECRET) return true;
const h = req.headers['x-bbe-webhook-secret'];
if (typeof h === 'string' && safeEqualStr(h, WEBHOOK_SECRET)) return true;
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const t = auth.slice(7);
if (safeEqualStr(t, WEBHOOK_SECRET)) return true;
}
const q = req.query.token;
const token = typeof q === 'string' ? q : Array.isArray(q) ? q[0] : '';
if (typeof token === 'string' && token.length > 0 && safeEqualStr(token, WEBHOOK_SECRET)) {
return true;
}
return false;
}
function safeEqualStr(a: string, b: string): boolean {
try {
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
} catch {
return false;
}
}
/** Public: board config for the frontend */
router.get('/config', (_req: Request, res: Response) => {
res.json({
priceSats: MESSAGE_PRICE_SATS,
bbeZapPubkey: process.env.BOARD_ZAP_PUBKEY || null,
bbeZapAddress: process.env.BOARD_ZAP_LN_ADDRESS || null,
});
});
/** Create Lightning invoice for a new message (no BoardMessage row yet). */
router.post('/invoice', async (req: Request, res: Response) => {
try {
const { content, name, pubkey, profilePic, postAsAnon } = req.body as Record<string, unknown>;
if (typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
const cleanContent = sanitizeText(content, MAX_CONTENT);
if (!cleanContent) {
res.status(400).json({ error: 'content is empty' });
return;
}
let guestName: string | null = null;
let authorPubkey: string | null = null;
let pic: string | null = null;
const pk = typeof pubkey === 'string' && /^[a-f0-9]{64}$/i.test(pubkey) ? pubkey.toLowerCase() : null;
const asAnon = postAsAnon === true;
if (pk && !asAnon) {
authorPubkey = pk;
if (typeof profilePic === 'string' && profilePic.length > 0 && profilePic.length < 2048) {
pic = profilePic.slice(0, 2048);
}
const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : '';
guestName = n || null;
} else {
const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : '';
guestName = n || null;
}
const webhookPath = WEBHOOK_SECRET
? `/api/messages/webhook?token=${encodeURIComponent(WEBHOOK_SECRET)}`
: '/api/messages/webhook';
const webhookUrl = `${WEBHOOK_BASE}${webhookPath}`;
const { payment_hash, payment_request, checking_id } = await createIncomingInvoice({
amountSats: MESSAGE_PRICE_SATS,
memo: 'BBE message board',
webhookUrl,
});
await prisma.boardInvoicePending.upsert({
where: { paymentHash: payment_hash },
create: {
paymentHash: payment_hash,
content: cleanContent,
guestName,
pubkey: authorPubkey,
profilePic: pic,
},
update: {
content: cleanContent,
guestName,
pubkey: authorPubkey,
profilePic: pic,
},
});
res.json({
payment_request,
checking_id,
payment_hash,
});
} catch (err) {
console.error('Board invoice error:', err);
const msg = err instanceof Error ? err.message : 'Internal server error';
if (msg.includes('LNBITS_API_KEY')) {
res.status(503).json({ error: 'Lightning payments are not configured' });
return;
}
res.status(500).json({ error: msg });
}
});
/**
* Shared logic: verify payment on LNbits, look up pending row, create BoardMessage.
* Returns { ok, duplicate?, ignored?, error? }.
*/
async function promotePendingToMessage(paymentHash: string): Promise<{
ok: boolean;
duplicate?: boolean;
ignored?: string;
error?: string;
}> {
const existing = await prisma.boardMessage.findUnique({ where: { paymentHash } });
if (existing) {
await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } });
return { ok: true, duplicate: true };
}
const verified = await verifyIncomingPaymentPaid(paymentHash);
if (!verified.paid) {
return { ok: true, ignored: 'not_paid' };
}
const expectedMsat = MESSAGE_PRICE_SATS * 1000;
if (verified.amountMsat != null && verified.amountMsat < expectedMsat) {
console.warn('Board: amount mismatch', paymentHash, verified.amountMsat);
return { ok: true, ignored: 'amount' };
}
const pending = await prisma.boardInvoicePending.findUnique({ where: { paymentHash } });
if (!pending) {
return { ok: true, ignored: 'no_pending' };
}
const authorName = pending.pubkey
? pending.guestName && pending.guestName.length > 0
? pending.guestName
: 'Nostr user'
: pending.guestName && pending.guestName.length > 0
? pending.guestName
: 'anon';
try {
await prisma.$transaction([
prisma.boardMessage.create({
data: {
paymentHash,
content: pending.content,
authorName,
pubkey: pending.pubkey,
profilePic: pending.profilePic,
satsPaid: MESSAGE_PRICE_SATS,
status: 'active',
},
}),
prisma.boardInvoicePending.delete({ where: { paymentHash } }),
]);
} catch (e: unknown) {
const code = e && typeof e === 'object' && 'code' in e ? (e as { code: string }).code : '';
if (code === 'P2002') {
await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } });
return { ok: true, duplicate: true };
}
throw e;
}
return { ok: true };
}
/** LNbits calls this when an invoice is paid */
router.post('/webhook', async (req: Request, res: Response) => {
try {
if (!verifyWebhookSecret(req)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const paymentHash = extractPaymentHash(req.body);
if (!paymentHash) {
const preview = typeof req.body === 'string' ? req.body.slice(0, 200) : JSON.stringify(req.body).slice(0, 200);
console.warn('Board webhook: could not extract payment_hash', typeof req.body, preview);
res.status(200).json({ ok: false, error: 'missing payment_hash' });
return;
}
const result = await promotePendingToMessage(paymentHash);
res.json(result);
} catch (err) {
console.error('Board webhook error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Client-side reconciliation: verify payment and create message if webhook was missed. */
router.post('/confirm/:paymentHash', async (req: Request, res: Response) => {
try {
const raw = req.params.paymentHash;
const paymentHash = Array.isArray(raw) ? raw[0] : raw;
if (!paymentHash || !HEX64.test(paymentHash)) {
res.status(400).json({ error: 'invalid payment_hash' });
return;
}
const result = await promotePendingToMessage(paymentHash.toLowerCase());
res.json(result);
} catch (err) {
console.error('Board confirm error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Poll payment status (server-side LNbits public API — avoids CORS). */
router.get('/payment/:paymentHash/status', async (req: Request, res: Response) => {
try {
const raw = req.params.paymentHash;
const paymentHash = Array.isArray(raw) ? raw[0] : raw;
if (!paymentHash || !/^[a-f0-9]{64}$/i.test(paymentHash)) {
res.status(400).json({ error: 'invalid payment_hash' });
return;
}
const hash = paymentHash.toLowerCase();
const paid = await getPublicPaymentStatus(hash);
const message = await prisma.boardMessage.findUnique({
where: { paymentHash: hash },
select: { id: true },
});
res.json({ paid: paid || !!message, messageCreated: !!message });
} catch (err) {
console.error('Board payment status error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Public list: active messages only */
router.get('/', async (_req: Request, res: Response) => {
try {
const messages = await prisma.boardMessage.findMany({
where: { status: 'active' },
orderBy: { createdAt: 'desc' },
select: {
id: true,
paymentHash: true,
content: true,
authorName: true,
pubkey: true,
profilePic: true,
satsPaid: true,
likeCount: true,
createdAt: true,
},
});
res.json(messages);
} catch (err) {
console.error('List board messages error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Like (Nostr-attributed messages only; logged-in users) */
router.post('/:id/like', requireAuth, async (req: Request, res: Response) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
const msg = await prisma.boardMessage.findUnique({ where: { id } });
if (!msg || msg.status !== 'active') {
res.status(404).json({ error: 'Message not found' });
return;
}
if (!msg.pubkey) {
res.status(400).json({ error: 'Likes are only for Nostr-attributed messages' });
return;
}
const updated = await prisma.boardMessage.update({
where: { id: msg.id },
data: { likeCount: { increment: 1 } },
select: { likeCount: true },
});
res.json({ likeCount: updated.likeCount });
} catch (err) {
console.error('Board like error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,132 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
try {
const organizers = await prisma.organizer.findMany({
orderBy: { name: 'asc' },
});
res.json(organizers);
} catch (err) {
console.error('List organizers error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/by-slug/:slug', async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { slug: req.params.slug as string },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
res.json(organizer);
} catch (err) {
console.error('Get organizer by slug error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { name, slug } = req.body;
if (!name || !slug) {
res.status(400).json({ error: 'name and slug are required' });
return;
}
const organizer = await prisma.organizer.create({
data: { name, slug },
});
res.status(201).json(organizer);
} catch (err: any) {
if (err?.code === 'P2002') {
res.status(400).json({ error: 'An organizer with this slug already exists' });
return;
}
console.error('Create organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { id: req.params.id as string },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
const { name, slug } = req.body;
const updateData: { name?: string; slug?: string } = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
const updated = await prisma.organizer.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err: any) {
if (err?.code === 'P2002') {
res.status(400).json({ error: 'An organizer with this slug already exists' });
return;
}
console.error('Update organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { id: req.params.id as string },
include: { _count: { select: { meetups: true } } },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
if (organizer._count.meetups > 0) {
res.status(400).json({
error: `Cannot delete organizer: ${organizer._count.meetups} event(s) still reference it`,
});
return;
}
await prisma.organizer.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,2 @@
/** Slug for the default organizer row (seed + migration). */
export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';

View File

@@ -10,6 +10,7 @@ import morgan from 'morgan';
import authRouter from './api/auth';
import postsRouter from './api/posts';
import meetupsRouter from './api/meetups';
import organizersRouter from './api/organizers';
import moderationRouter from './api/moderation';
import usersRouter from './api/users';
import categoriesRouter from './api/categories';
@@ -21,6 +22,8 @@ import mediaRouter from './api/media';
import faqsRouter from './api/faqs';
import calendarRouter from './api/calendar';
import nip05Router from './api/nip05';
import messagesRouter from './api/messages';
import adminMessagesRouter from './api/adminMessages';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
@@ -37,6 +40,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/meetups', meetupsRouter);
app.use('/api/organizers', organizersRouter);
app.use('/api/moderation', moderationRouter);
app.use('/api/users', usersRouter);
app.use('/api/categories', categoriesRouter);
@@ -48,6 +52,8 @@ app.use('/api/media', mediaRouter);
app.use('/api/faqs', faqsRouter);
app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.use('/api/messages', messagesRouter);
app.use('/api/admin/messages', adminMessagesRouter);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });

View File

@@ -0,0 +1,36 @@
import type { Response } from 'express';
/**
* When the DB was never migrated for organizers (or db push failed), Prisma throws.
* Return a clear JSON error so operators know to run `prisma migrate deploy`, not `db push`.
*/
export function respondIfOrganizerMigrationNeeded(err: unknown, res: Response): boolean {
const e = err as {
code?: string;
meta?: { modelName?: string; table?: string; column?: string };
message?: string;
};
const msg = String(e?.message ?? '');
if (e?.code === 'P2021') {
const model = e.meta?.modelName ?? '';
const table = e.meta?.table ?? '';
if (model === 'Organizer' || table.includes('Organizer')) {
res.status(503).json({
error:
'Database is missing the Organizer table. On the server run: cd backend && npm run migrate:deploy (use Prisma migrate, not db push).',
});
return true;
}
}
if (e?.code === 'P2022' && (msg.includes('organizerId') || e.meta?.column === 'organizerId')) {
res.status(503).json({
error:
'Database is missing meetup organizer columns. On the server run: cd backend && npm run migrate:deploy (do not use prisma db push for this upgrade).',
});
return true;
}
return false;
}

View File

@@ -0,0 +1,113 @@
const LNBITS_URL = (process.env.LNBITS_URL || 'https://legend.lnbits.com').replace(/\/$/, '');
const LNBITS_API_KEY = process.env.LNBITS_API_KEY || '';
export interface CreateInvoiceResult {
payment_hash: string;
payment_request: string;
checking_id: string;
}
function pickBolt11(data: Record<string, unknown>): string {
const pr = data.payment_request ?? data.bolt11;
if (typeof pr === 'string' && pr.length > 0) return pr;
return '';
}
export async function createIncomingInvoice(params: {
amountSats: number;
memo: string;
webhookUrl: string;
expirySeconds?: number;
}): Promise<CreateInvoiceResult> {
if (!LNBITS_API_KEY) {
throw new Error('LNBITS_API_KEY is not configured');
}
const body: Record<string, unknown> = {
out: false,
amount: params.amountSats,
unit: 'sat',
memo: params.memo,
expiry: params.expirySeconds ?? 3600,
webhook: params.webhookUrl,
};
const res = await fetch(`${LNBITS_URL}/api/v1/payments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': LNBITS_API_KEY,
},
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
const detail =
typeof data.detail === 'string'
? data.detail
: Array.isArray(data.detail)
? JSON.stringify(data.detail)
: typeof data.message === 'string'
? data.message
: res.statusText;
throw new Error(`LNbits invoice failed: ${detail || res.status}`);
}
const payment_hash = typeof data.payment_hash === 'string' ? data.payment_hash : '';
const payment_request = pickBolt11(data);
const checking_id =
typeof data.checking_id === 'string' && data.checking_id.length > 0
? data.checking_id
: payment_hash;
if (!payment_hash || !payment_request) {
throw new Error('LNbits returned an unexpected invoice payload');
}
return { payment_hash, payment_request, checking_id };
}
export interface PaymentVerifyResult {
paid: boolean;
amountMsat?: number;
}
/** Verify invoice belongs to our wallet and is paid (requires API key). */
export async function verifyIncomingPaymentPaid(paymentHash: string): Promise<PaymentVerifyResult> {
if (!LNBITS_API_KEY) {
throw new Error('LNBITS_API_KEY is not configured');
}
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`, {
headers: { 'X-Api-Key': LNBITS_API_KEY },
});
if (res.status === 404) {
return { paid: false };
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
return { paid: false };
}
const paid = data.paid === true;
let amountMsat: number | undefined;
const details = data.details as Record<string, unknown> | undefined;
if (details && typeof details.amount === 'number') {
amountMsat = details.amount;
}
return { paid, amountMsat };
}
/** Public LNbits poll (no key): { paid: boolean } */
export async function getPublicPaymentStatus(paymentHash: string): Promise<boolean> {
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`);
if (!res.ok) return false;
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
return data.paid === true;
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { slugify } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Plus,
@@ -23,7 +23,7 @@ import {
Check,
} from "lucide-react";
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
interface Meetup {
id: string;
@@ -37,6 +37,8 @@ interface Meetup {
status: string;
featured: boolean;
visibility: string;
organizerId?: string;
organizer?: { id: string; name: string; slug: string };
createdAt: string;
updatedAt: string;
}
@@ -52,6 +54,7 @@ interface MeetupForm {
status: string;
featured: boolean;
visibility: string;
organizerId: string;
}
const emptyForm: MeetupForm = {
@@ -65,8 +68,17 @@ const emptyForm: MeetupForm = {
status: "DRAFT",
featured: false,
visibility: "PUBLIC",
organizerId: "",
};
function defaultOrganizerId(organizers: { id: string; slug: string }[]): string {
return (
organizers.find((o) => o.slug === "belgian-bitcoin-embassy")?.id ||
organizers[0]?.id ||
""
);
}
// Statuses that can be manually set by an admin
const EDITABLE_STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "CANCELLED"] as const;
type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number];
@@ -229,10 +241,20 @@ export default function EventsPage() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
const [organizers, setOrganizers] = useState<{ id: string; name: string; slug: string }[]>([]);
const [showAddOrganizer, setShowAddOrganizer] = useState(false);
const [newOrgName, setNewOrgName] = useState("");
const [newOrgSlug, setNewOrgSlug] = useState("");
const [savingOrganizer, setSavingOrganizer] = useState(false);
const loadMeetups = async () => {
try {
const data = await api.getMeetups({ admin: true });
const [data, orgs] = await Promise.all([
api.getMeetups({ admin: true }),
api.getOrganizers(),
]);
setMeetups(data as Meetup[]);
setOrganizers(orgs);
} catch (err: any) {
setError(err.message);
} finally {
@@ -245,13 +267,38 @@ export default function EventsPage() {
}, []);
const openCreate = () => {
setForm(emptyForm);
setForm({ ...emptyForm, organizerId: defaultOrganizerId(organizers) });
setShowAddOrganizer(false);
setNewOrgName("");
setNewOrgSlug("");
setEditingId(null);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const handleCreateOrganizer = async () => {
const slug = newOrgSlug.trim() || slugify(newOrgName);
if (!newOrgName.trim() || !slug) return;
setSavingOrganizer(true);
setError("");
try {
const o = await api.createOrganizer({ name: newOrgName.trim(), slug });
setOrganizers((prev) => [...prev, o].sort((a, b) => a.name.localeCompare(b.name)));
setForm((f) => ({ ...f, organizerId: o.id }));
setShowAddOrganizer(false);
setNewOrgName("");
setNewOrgSlug("");
} catch (err: any) {
setError(err.message);
} finally {
setSavingOrganizer(false);
}
};
const openEdit = (meetup: Meetup) => {
setShowAddOrganizer(false);
setNewOrgName("");
setNewOrgSlug("");
setForm({
title: meetup.title,
description: meetup.description || "",
@@ -263,6 +310,7 @@ export default function EventsPage() {
status: meetup.status || "DRAFT",
featured: meetup.featured || false,
visibility: meetup.visibility || "PUBLIC",
organizerId: meetup.organizerId || meetup.organizer?.id || defaultOrganizerId(organizers),
});
setEditingId(meetup.id);
setShowForm(true);
@@ -284,6 +332,7 @@ export default function EventsPage() {
status: form.status,
featured: form.featured,
visibility: form.visibility,
organizerId: form.organizerId || undefined,
};
if (editingId) {
@@ -502,6 +551,61 @@ export default function EventsPage() {
<option value="HIDDEN">Hidden</option>
</select>
</div>
<div className="md:col-span-2 space-y-3">
<label className="text-on-surface/60 text-xs block">Organizer</label>
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<select
value={form.organizerId}
onChange={(e) => setForm({ ...form, organizerId: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full sm:flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{organizers.length === 0 ? (
<option value="">No organizers add one in Organizers</option>
) : (
organizers.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))
)}
</select>
<button
type="button"
onClick={() => setShowAddOrganizer((v) => !v)}
className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/80 hover:text-on-surface text-sm font-medium shrink-0"
>
{showAddOrganizer ? "Cancel add" : "Add new organizer"}
</button>
</div>
{showAddOrganizer && (
<div className="rounded-lg border border-surface-container-highest p-4 space-y-3">
<input
placeholder="Organizer name"
value={newOrgName}
onChange={(e) => {
const name = e.target.value;
setNewOrgName(name);
setNewOrgSlug(slugify(name));
}}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
placeholder="URL slug"
value={newOrgSlug}
onChange={(e) => setNewOrgSlug(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
type="button"
onClick={handleCreateOrganizer}
disabled={savingOrganizer || !newOrgName.trim()}
className="px-4 py-2 rounded-lg bg-primary/20 text-primary font-semibold text-sm hover:bg-primary/30 disabled:opacity-50"
>
{savingOrganizer ? "Creating…" : "Create and select"}
</button>
</div>
)}
</div>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
External registration link{" "}
@@ -579,7 +683,12 @@ export default function EventsPage() {
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.title || !form.date}
disabled={
saving ||
!form.title ||
!form.date ||
(!editingId && !form.organizerId)
}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
@@ -761,13 +870,18 @@ export default function EventsPage() {
<EyeOff size={12} className="text-on-surface/40 shrink-0" />
)}
</div>
{meetup.organizer?.name && (
<p className="text-on-surface/40 text-xs mb-1 truncate">
{meetup.organizer.name}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<StatusDropdown
meetup={meetup}
onChange={(v) => handlePatch(meetup.id, { status: v })}
/>
<span className="text-on-surface/50 text-xs">
{meetup.date ? formatDate(meetup.date) : "No date"}
{meetup.date ? formatMeetupCivilDateLong(meetup.date) : "No date"}
{meetup.location && ` · ${meetup.location}`}
</span>
</div>

View File

@@ -0,0 +1,146 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Eye, EyeOff, Trash2 } from "lucide-react";
type Row = {
id: string;
paymentHash: string;
content: string;
authorName: string;
pubkey: string | null;
satsPaid: number;
status: string;
likeCount: number;
createdAt: string;
};
export default function AdminBoardMessagesPage() {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
try {
const data = await api.getAdminBoardMessages();
setRows(data);
setError("");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const toggleHide = async (id: string) => {
try {
await api.hideBoardMessage(id);
await load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Hide failed");
}
};
const softDelete = async (id: string) => {
if (!confirm("Mark this message as deleted? It will disappear from the public board.")) return;
try {
await api.deleteBoardMessage(id);
await load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Delete failed");
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-on-surface/50">Loading board messages</p>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Message board</h1>
<p className="text-on-surface-variant text-sm max-w-2xl">
Lightning-paid public messages. Hide toggles visibility on the site; delete marks a row as removed
without dropping history.
</p>
{error && <p className="text-error text-sm">{error}</p>}
<div className="overflow-x-auto rounded-xl border border-outline-variant/30">
<table className="w-full text-sm text-left">
<thead className="bg-surface-container-high text-on-surface-variant uppercase text-xs">
<tr>
<th className="px-4 py-3 font-semibold">Content</th>
<th className="px-4 py-3 font-semibold">Author</th>
<th className="px-4 py-3 font-semibold">Sats</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Date</th>
<th className="px-4 py-3 font-semibold w-40">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/20">
{rows.map((r) => (
<tr key={r.id} className="bg-surface-container-low hover:bg-surface-container/80">
<td className="px-4 py-3 max-w-md">
<p className="text-on-surface line-clamp-3 whitespace-pre-wrap break-words">{r.content}</p>
</td>
<td className="px-4 py-3 text-on-surface whitespace-nowrap">{r.authorName}</td>
<td className="px-4 py-3 font-mono text-primary">{r.satsPaid}</td>
<td className="px-4 py-3">
<span
className={
r.status === "active"
? "text-green-600 font-medium"
: r.status === "hidden"
? "text-amber-600 font-medium"
: "text-on-surface-variant"
}
>
{r.status}
</span>
</td>
<td className="px-4 py-3 text-on-surface-variant whitespace-nowrap">
{formatDate(r.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggleHide(r.id)}
disabled={r.status === "deleted"}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-surface-container-high text-on-surface text-xs font-medium hover:bg-surface-container disabled:opacity-40"
title={r.status === "hidden" ? "Unhide" : "Hide"}
>
{r.status === "hidden" ? <Eye size={14} /> : <EyeOff size={14} />}
{r.status === "hidden" ? "Unhide" : "Hide"}
</button>
<button
type="button"
onClick={() => softDelete(r.id)}
disabled={r.status === "deleted"}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-error/15 text-error text-xs font-medium hover:bg-error/25 disabled:opacity-40"
>
<Trash2 size={14} />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<p className="p-8 text-center text-on-surface-variant">No board messages yet.</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { slugify } from "@/lib/utils";
import { Plus, Pencil, Trash2, X } from "lucide-react";
interface OrganizerForm {
name: string;
slug: string;
}
export default function OrganizersPage() {
const [organizers, setOrganizers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<OrganizerForm>({ name: "", slug: "" });
const [saving, setSaving] = useState(false);
const loadOrganizers = async () => {
try {
const data = await api.getOrganizers();
setOrganizers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadOrganizers();
}, []);
const openCreate = () => {
setForm({ name: "", slug: "" });
setEditingId(null);
setShowForm(true);
};
const openEdit = (org: any) => {
setForm({ name: org.name, slug: org.slug });
setEditingId(org.id);
setShowForm(true);
};
const handleNameChange = (name: string) => {
setForm({ name, slug: editingId ? form.slug : slugify(name) });
};
const handleSave = async () => {
if (!form.name.trim() || !form.slug.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateOrganizer(editingId, form);
} else {
await api.createOrganizer(form);
}
setShowForm(false);
setEditingId(null);
await loadOrganizers();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this organizer?")) return;
try {
await api.deleteOrganizer(id);
await loadOrganizers();
} catch (err: any) {
setError(err.message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading organizers...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Organizers</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Add Organizer
</button>
</div>
<p className="text-on-surface/60 text-sm max-w-2xl">
Organizers appear on public event cards and detail pages. The default is Belgian Bitcoin Embassy;
add other Belgian meetup groups so their events can be listed on this site.
</p>
{error && <p className="text-error text-sm">{error}</p>}
{showForm && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Organizer" : "New Organizer"}
</h2>
<button onClick={() => setShowForm(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
placeholder="Display name"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
placeholder="URL slug (e.g. antwerp-bitcoin)"
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{organizers.length === 0 ? (
<p className="text-on-surface/50 text-sm">No organizers found.</p>
) : (
organizers.map((org) => (
<div
key={org.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<h3 className="text-on-surface font-semibold">{org.name}</h3>
<p className="text-on-surface/50 text-sm">/events/organizer/{org.slug}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(org)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(org.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatDate } from "@/lib/utils";
import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react";
import Link from "next/link";
@@ -94,7 +94,7 @@ export default function OverviewPage() {
<h2 className="text-lg font-semibold text-on-surface mb-3">Next Upcoming Meetup</h2>
<p className="text-primary font-semibold">{upcomingMeetup.title}</p>
<p className="text-on-surface/60 text-sm mt-1">
{formatDate(upcomingMeetup.date)} · {upcomingMeetup.location}
{formatMeetupCivilDateLong(upcomingMeetup.date)} · {upcomingMeetup.location}
</p>
</div>
)}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Message Board — Pay with Lightning",
description:
"Post a public message on the Belgian Bitcoin Embassy board. Pay a small Lightning invoice via LNbits to publish.",
openGraph: {
title: "Message Board — Belgian Bitcoin Embassy",
description: "Pay with Lightning to post a message.",
},
alternates: { canonical: "/board" },
};
export default function BoardLayout({ children }: { children: React.ReactNode }) {
return children;
}

449
frontend/app/board/page.tsx Normal file
View File

@@ -0,0 +1,449 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { QRCodeSVG } from "qrcode.react";
import { nip19 } from "nostr-tools";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { Button } from "@/components/ui/Button";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { shortenPubkey } from "@/lib/nostr";
import { Copy, Check, Heart, Zap } from "lucide-react";
type BoardMessage = {
id: string;
paymentHash: string;
content: string;
authorName: string;
pubkey: string | null;
profilePic: string | null;
satsPaid: number;
likeCount: number;
createdAt: string;
};
type PayPhase = "idle" | "polling" | "confirming" | "paid";
const MAX_LEN = 300;
function BoardAvatar({
picture,
name,
size = 40,
}: {
picture?: string | null;
name: string;
size?: number;
}) {
const [err, setErr] = useState(false);
const initial = name.slice(0, 1).toUpperCase() || "?";
if (picture && !err) {
return (
<Image
src={picture}
alt=""
width={size}
height={size}
className="rounded-full object-cover shrink-0"
style={{ width: size, height: size }}
onError={() => setErr(true)}
unoptimized
/>
);
}
return (
<div
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm shrink-0"
style={{ width: size, height: size }}
>
{initial}
</div>
);
}
export default function BoardPage() {
const { user } = useAuth();
const [config, setConfig] = useState<{
priceSats: number;
bbeZapPubkey: string | null;
bbeZapAddress: string | null;
} | null>(null);
const [messages, setMessages] = useState<BoardMessage[]>([]);
const [content, setContent] = useState("");
const [guestName, setGuestName] = useState("");
const [postAsAnon, setPostAsAnon] = useState(false);
const [payPhase, setPayPhase] = useState<PayPhase>("idle");
const [payError, setPayError] = useState("");
const [invoice, setInvoice] = useState<{ pr: string; hash: string } | null>(null);
const [copied, setCopied] = useState(false);
const [localLikes, setLocalLikes] = useState<Record<string, number>>({});
const [highlightPaymentHash, setHighlightPaymentHash] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadMessages = useCallback(async () => {
const list = await api.getBoardMessages();
setMessages(list as BoardMessage[]);
}, []);
const loadConfig = useCallback(async () => {
const c = await api.getBoardConfig();
setConfig(c);
}, []);
useEffect(() => {
loadConfig().catch(() => {});
}, [loadConfig]);
useEffect(() => {
loadMessages().catch(() => {});
const t = setInterval(() => {
loadMessages().catch(() => {});
}, 12_000);
return () => clearInterval(t);
}, [loadMessages]);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const displayName = user?.name || user?.displayName || "";
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : "";
const shortNpub = npub.length > 20 ? `${npub.slice(0, 14)}${npub.slice(-12)}` : npub;
const handlePayAndPost = async () => {
setPayError("");
const trimmed = content.trim();
if (!trimmed) {
setPayError("Write a message first.");
return;
}
if (trimmed.length > MAX_LEN) {
setPayError(`Message must be ${MAX_LEN} characters or fewer.`);
return;
}
try {
const body: Parameters<typeof api.createBoardInvoice>[0] = { content: trimmed };
if (user?.pubkey && !postAsAnon) {
body.pubkey = user.pubkey;
body.name = displayName || undefined;
body.profilePic = user.picture;
} else {
const n = guestName.trim();
if (n) body.name = n;
}
body.postAsAnon = !!(user?.pubkey && postAsAnon);
const inv = await api.createBoardInvoice(body);
setHighlightPaymentHash(inv.payment_hash);
setInvoice({ pr: inv.payment_request, hash: inv.payment_hash });
setPayPhase("polling");
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status = await api.getBoardPaymentStatus(inv.payment_hash);
if (!status.paid) return;
// Payment detected — try to ensure the message row exists
setPayPhase("confirming");
setInvoice(null);
if (!status.messageCreated) {
// Webhook may not have fired yet; call confirm to create the row
await api.confirmBoardPayment(inv.payment_hash).catch(() => {});
}
// Poll until the message actually appears in the list (max ~30s)
let found = false;
for (let attempt = 0; attempt < 15; attempt++) {
const list = await api.getBoardMessages();
setMessages(list as BoardMessage[]);
if (list.some((m) => m.paymentHash === inv.payment_hash)) {
found = true;
break;
}
// Try confirm again if first attempt didn't work
if (attempt === 2) {
await api.confirmBoardPayment(inv.payment_hash).catch(() => {});
}
await new Promise((r) => setTimeout(r, 2000));
}
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
if (found) {
setPayPhase("paid");
setContent("");
setGuestName("");
setPostAsAnon(false);
setTimeout(() => {
setPayPhase("idle");
setHighlightPaymentHash(null);
}, 4000);
} else {
setPayPhase("idle");
setPayError("Payment received but message is delayed — it will appear shortly.");
}
} catch {
/* keep polling */
}
}, 2000);
} catch (e: unknown) {
setPayPhase("idle");
setPayError(e instanceof Error ? e.message : "Could not create invoice.");
}
};
const copyPr = async () => {
if (!invoice?.pr) return;
await navigator.clipboard.writeText(invoice.pr);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const bumpLike = async (msg: BoardMessage) => {
if (!user) return;
try {
const { likeCount } = await api.likeBoardMessage(msg.id);
setLocalLikes((prev) => ({ ...prev, [msg.id]: likeCount }));
} catch (e: unknown) {
setPayError(e instanceof Error ? e.message : "Like failed");
}
};
const handleZap = async (msg: BoardMessage) => {
const zapAddress = config?.bbeZapAddress;
const fallbackPub = config?.bbeZapPubkey;
try {
const w = window as unknown as {
nostr?: { zap?: (args: Record<string, unknown>) => Promise<unknown> };
};
if (msg.pubkey && w.nostr?.zap) {
await w.nostr.zap({
pubkey: msg.pubkey,
amount: 21,
comment: "BBE board zap",
});
return;
}
} catch {
/* fall through */
}
if (msg.pubkey) {
window.open(`https://njump.me/${nip19.npubEncode(msg.pubkey)}`, "_blank", "noopener,noreferrer");
return;
}
if (zapAddress) {
const addr = zapAddress.replace(/^lightning:/i, "");
window.location.href = `lightning:${addr}`;
return;
}
if (fallbackPub) {
try {
window.open(
`https://njump.me/${nip19.npubEncode(fallbackPub)}`,
"_blank",
"noopener,noreferrer"
);
} catch {
setPayError("Invalid BOARD_ZAP_PUBKEY on server (expected hex pubkey).");
}
return;
}
setPayError("Zap: configure BOARD_ZAP_LN_ADDRESS or BOARD_ZAP_PUBKEY on the server.");
};
const len = content.length;
return (
<>
<Navbar />
<div className="min-h-screen pb-20">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-10">
<h1 className="text-4xl font-black mb-2">Message board</h1>
<p className="text-on-surface-variant text-lg mb-10">
Pay {config?.priceSats ?? "…"} sats via Lightning to post. Messages are public and moderated.
</p>
<section className="rounded-2xl border border-outline-variant/30 bg-surface-container-low p-6 mb-12">
<h2 className="text-lg font-bold text-on-surface mb-4">Post a message</h2>
{user && !postAsAnon ? (
<div className="flex items-center gap-3 mb-4 p-3 rounded-xl bg-surface-container">
<BoardAvatar picture={user.picture} name={displayName || "You"} />
<div className="min-w-0">
<p className="font-semibold text-on-surface truncate">{displayName || "Nostr user"}</p>
<p className="text-xs font-mono text-on-surface-variant truncate" title={npub}>
{shortNpub}
</p>
</div>
</div>
) : (
<div className="space-y-3 mb-4">
{user && (
<label className="flex items-center gap-2 text-sm text-on-surface-variant cursor-pointer">
<input
type="checkbox"
checked={postAsAnon}
onChange={(e) => setPostAsAnon(e.target.checked)}
className="rounded border-outline-variant"
/>
Post as anon (hide Nostr profile)
</label>
)}
{!user || postAsAnon ? (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1">
Name (optional)
</label>
<input
type="text"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder="Guest"
maxLength={80}
className="w-full rounded-lg border border-outline-variant bg-surface px-3 py-2 text-on-surface"
/>
</div>
) : null}
</div>
)}
<textarea
value={content}
onChange={(e) => setContent(e.target.value.slice(0, MAX_LEN))}
placeholder="Whats on your mind?"
rows={4}
maxLength={MAX_LEN}
className="w-full rounded-xl border border-outline-variant bg-surface px-4 py-3 text-on-surface placeholder:text-on-surface-variant/50 resize-y min-h-[120px]"
/>
<div className="flex justify-between items-center mt-2 text-sm text-on-surface-variant">
<span>
{len}/{MAX_LEN}
</span>
{payPhase === "polling" && (
<span className="text-primary font-medium animate-pulse">Awaiting payment</span>
)}
{payPhase === "confirming" && (
<span className="text-primary font-medium animate-pulse">Payment received publishing</span>
)}
{payPhase === "paid" && <span className="text-green-500 font-medium">Posted!</span>}
</div>
{payError && <p className="text-error text-sm mt-3">{payError}</p>}
<div className="mt-4 flex flex-wrap gap-3">
<Button
variant="primary"
size="md"
onClick={handlePayAndPost}
disabled={payPhase === "polling" || payPhase === "confirming" || !content.trim()}
>
Pay & post
</Button>
</div>
{invoice && payPhase === "polling" && (
<div className="mt-8 p-4 rounded-xl bg-surface-container border border-outline-variant/40">
<p className="text-sm text-on-surface-variant mb-3">Scan or copy the Lightning invoice:</p>
<div className="flex flex-col sm:flex-row gap-6 items-start">
<div className="bg-white p-3 rounded-lg shrink-0">
<QRCodeSVG value={invoice.pr} size={180} level="M" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<button
type="button"
onClick={copyPr}
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Copied" : "Copy invoice"}
</button>
<p className="text-xs font-mono break-all text-on-surface-variant max-h-32 overflow-y-auto">
{invoice.pr}
</p>
</div>
</div>
</div>
)}
</section>
<section>
<h2 className="text-xl font-bold mb-4">Messages</h2>
<ul className="space-y-4">
{messages.map((m) => {
const likes = localLikes[m.id] ?? m.likeCount;
const isNew = highlightPaymentHash && m.paymentHash === highlightPaymentHash;
return (
<li
key={m.id}
className={cn(
"rounded-2xl border p-5 transition-shadow duration-500",
isNew
? "border-primary bg-primary-container/10 shadow-lg shadow-primary/20"
: "border-outline-variant/30 bg-surface-container-low"
)}
>
<div className="flex gap-3">
<BoardAvatar picture={m.profilePic} name={m.authorName} />
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-baseline gap-2 mb-1">
<span className="font-bold text-on-surface">{m.authorName}</span>
<span className="text-xs text-on-surface-variant">{formatDate(m.createdAt)}</span>
<span className="text-xs font-mono text-primary"> {m.satsPaid} sats</span>
</div>
<p className="text-on-surface whitespace-pre-wrap break-words">{m.content}</p>
<div className="flex flex-wrap gap-2 mt-4">
{m.pubkey && user ? (
<button
type="button"
onClick={() => bumpLike(m)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
>
<Heart size={16} className="text-red-400" />
{likes}
</button>
) : m.pubkey ? (
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface-variant">
<Heart size={16} className="text-red-400/70" />
{likes}
</span>
) : null}
<button
type="button"
onClick={() => handleZap(m)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
>
<Zap size={16} className="text-amber-400" />
Zap
</button>
</div>
</div>
</div>
</li>
);
})}
</ul>
{messages.length === 0 && (
<p className="text-on-surface-variant text-center py-12">No messages yet be the first.</p>
)}
</section>
</div>
</div>
<Footer />
</>
);
}

View File

@@ -1,8 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { Send, Zap, ExternalLink } from "lucide-react";
import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
export const metadata: Metadata = {
title: "Contact Us",
@@ -28,56 +27,7 @@ export default function ContactPage() {
decentralized community there is no central office or email inbox.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<a
href="https://t.me/belgianbitcoinembassy"
target="_blank"
rel="noopener noreferrer"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Send size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">Telegram</h2>
<p className="text-on-surface-variant text-sm">
Join our Telegram group for quick questions and community chat.
</p>
</a>
<a
href="#"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Zap size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">Nostr</h2>
<p className="text-on-surface-variant text-sm">
Follow us on Nostr for censorship-resistant communication.
</p>
</a>
<a
href="#"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<ExternalLink size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">X (Twitter)</h2>
<p className="text-on-surface-variant text-sm">
Follow us on X for announcements and updates.
</p>
</a>
<div className="bg-surface-container-low p-8 rounded-xl">
<h2 className="text-xl font-bold mb-2">Meetups</h2>
<p className="text-on-surface-variant text-sm mb-4">
The best way to connect is in person. Come to our monthly meetup
in Brussels.
</p>
<Link
href="/#meetup"
className="text-primary font-bold text-sm hover:underline"
>
See next meetup
</Link>
</div>
</div>
<ContactChannelGrid />
</div>
</div>
<Footer />

View File

@@ -16,9 +16,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
router.push("/login");
return;
}
if (user.role === "ADMIN" || user.role === "MODERATOR") {
router.push("/admin/overview");
}
}, [user, loading, router]);
if (loading) {
@@ -33,7 +30,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
if (!user) {
return null;
}

View File

@@ -2,25 +2,17 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink } from "lucide-react";
import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink, Building2 } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
function formatFullDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
import { UpcomingEventsCarousel } from "@/components/public/UpcomingEventsCarousel";
import { formatMeetupCivilDate, formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
function DateBadge({ dateStr }: { dateStr: string }) {
const d = new Date(dateStr);
const month = d.toLocaleString("en-US", { month: "short" }).toUpperCase();
const day = String(d.getDate());
const civil = formatMeetupCivilDate(dateStr);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
return (
<div className="bg-zinc-800 rounded-xl px-4 py-3 text-center shrink-0 min-w-[60px]">
<span className="block text-[11px] font-bold uppercase text-primary tracking-wider leading-none mb-1">
@@ -61,7 +53,12 @@ export default function EventDetailClient({ id }: { id: string }) {
.finally(() => setLoading(false));
}, [id]);
const isPast = meetup ? new Date(meetup.date) < new Date() : false;
const isPast = meetup
? (() => {
const start = getMeetupStartUtc(meetup.date, meetup.time || "00:00");
return !Number.isNaN(start.getTime()) && start < new Date();
})()
: false;
return (
<>
@@ -115,10 +112,35 @@ export default function EventDetailClient({ id }: { id: string }) {
</div>
</div>
<div className="flex flex-wrap gap-4 mb-6 text-sm text-on-surface-variant">
{meetup.organizer?.slug ? (
<Link
href={`/events/organizer/${meetup.organizer.slug}`}
className="flex items-center gap-2 text-primary hover:underline underline-offset-4"
>
<Building2 size={15} className="text-primary/70 shrink-0" />
<span>
Organized by{" "}
<span className="font-semibold">
{meetup.organizer.name || "Belgian Bitcoin Embassy"}
</span>
</span>
</Link>
) : (
<div className="flex items-center gap-2">
<Building2 size={15} className="text-primary/70 shrink-0" />
<span>
Organized by{" "}
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 mb-10 text-sm text-on-surface-variant">
<div className="flex items-center gap-2">
<Calendar size={15} className="text-primary/70 shrink-0" />
{formatFullDate(meetup.date)}
{formatMeetupCivilDateLong(meetup.date)}
</div>
{meetup.time && (
<div className="flex items-center gap-2">
@@ -152,6 +174,8 @@ export default function EventDetailClient({ id }: { id: string }) {
Register for this event <ExternalLink size={16} />
</a>
)}
<UpcomingEventsCarousel excludeId={meetup.id} />
</>
)}
</div>

View File

@@ -26,9 +26,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: "Event Not Found" };
}
const orgLabel = event.organizer?.name || "Belgian Bitcoin Embassy";
const description =
event.description?.slice(0, 160) ||
`Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by the Belgian Bitcoin Embassy.`;
`Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by ${orgLabel}.`;
const ogImage = event.imageId
? `/media/${event.imageId}`
@@ -69,6 +70,12 @@ export default async function EventDetailPage({ params }: Props) {
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
organizerName={event.organizer?.name}
organizerUrl={
event.organizer?.slug
? `${siteUrl}/events/organizer/${event.organizer.slug}`
: undefined
}
/>
<BreadcrumbJsonLd
items={[

View File

@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { MeetupCard } from "@/components/public/MeetupCard";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { ArrowLeft } from "lucide-react";
function CardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
</div>
</div>
</div>
);
}
export default function OrganizerEventsClient({
slug,
organizerName,
}: {
slug: string;
organizerName: string;
}) {
const [meetups, setMeetups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
api
.getMeetups({ organizerSlug: slug })
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
setMeetups(list);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
const now = new Date();
const upcoming = meetups.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start >= now;
});
const past = meetups
.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start < now;
})
.reverse();
return (
<>
<Navbar />
<div className="min-h-screen">
<header className="pt-24 pb-12 px-8">
<div className="max-w-6xl mx-auto">
<Link
href="/events"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-6 text-sm font-medium"
>
<ArrowLeft size={16} />
All events
</Link>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Organizer
</p>
<h1 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">
{organizerName}
</h1>
<p className="text-on-surface-variant max-w-md leading-relaxed">
Upcoming and past events hosted by this organizer.
</p>
</div>
</header>
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load events: {error}
</div>
)}
<div>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-black flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
<AddToCalendarButton />
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => (
<CardSkeleton key={i} />
))}
</div>
) : upcoming.length === 0 ? (
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming events from this organizer.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{upcoming.map((m) => (
<MeetupCard key={m.id} meetup={m} />
))}
</div>
)}
</div>
{(loading || past.length > 0) && (
<div>
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">Past events</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => (
<CardSkeleton key={i} />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => (
<MeetupCard key={m.id} meetup={m} muted />
))}
</div>
)}
</div>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import OrganizerEventsClient from "./OrganizerEventsClient";
import { apiUrl } from "@/lib/api-base";
async function fetchOrganizer(slug: string) {
try {
const res = await fetch(apiUrl(`/organizers/by-slug/${encodeURIComponent(slug)}`), {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json() as Promise<{ id: string; name: string; slug: string }>;
} catch {
return null;
}
}
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const org = await fetchOrganizer(slug);
if (!org) {
return { title: "Organizer not found" };
}
return {
title: `Events by ${org.name}`,
description: `Upcoming and past Bitcoin events organized by ${org.name} in Belgium.`,
alternates: { canonical: `/events/organizer/${slug}` },
};
}
export default async function OrganizerArchivePage({ params }: Props) {
const { slug } = await params;
const org = await fetchOrganizer(slug);
if (!org) {
notFound();
}
return <OrganizerEventsClient slug={slug} organizerName={org.name} />;
}

View File

@@ -1,80 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
function formatMeetupDate(dateStr: string) {
const d = new Date(dateStr);
return {
month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
day: String(d.getDate()),
full: d.toLocaleString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}),
};
}
function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
const { month, day, full } = formatMeetupDate(meetup.date);
return (
<Link
href={`/events/${meetup.id}`}
className={`group flex flex-col bg-zinc-900 border rounded-xl p-6 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 ${
muted
? "border-zinc-800/60 opacity-70 hover:opacity-100 hover:border-zinc-700"
: "border-zinc-800 hover:border-zinc-700"
}`}
>
<div className="flex items-start gap-4 mb-4">
<div className={`rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px] ${muted ? "bg-zinc-800/60" : "bg-zinc-800"}`}>
<span className={`block text-[10px] font-bold uppercase tracking-wider leading-none mb-0.5 ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="min-w-0">
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
{meetup.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
</div>
</div>
{meetup.description && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{meetup.description}
</p>
)}
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.location}
</p>
)}
{meetup.time && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<Clock size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.time}
</p>
)}
</div>
<span className={`flex items-center gap-1.5 text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
View Details <ArrowRight size={12} />
</span>
</Link>
);
}
import { MeetupCard } from "@/components/public/MeetupCard";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
function CardSkeleton() {
return (
@@ -150,14 +82,17 @@ export default function EventsPage() {
)}
<div>
<h2 className="text-xl font-black mb-8 flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-black flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
<AddToCalendarButton />
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">

View File

@@ -1,10 +1,21 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { ClientProviders } from "@/components/providers/ClientProviders";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN?.trim();
const plausibleAnalyticsOrigin = process.env.NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN?.trim().replace(
/\/$/,
"",
);
const plausibleScriptSrc =
plausibleDomain && plausibleAnalyticsOrigin
? `${plausibleAnalyticsOrigin}/js/script.js`
: null;
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
@@ -83,6 +94,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" dir="ltr" className="dark">
<body>
{plausibleScriptSrc && plausibleDomain ? (
<Script
defer
src={plausibleScriptSrc}
data-domain={plausibleDomain}
strategy="afterInteractive"
/>
) : null}
<OrganizationJsonLd />
<WebSiteJsonLd />
<ClientProviders>{children}</ClientProviders>

View File

@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { Navbar } from "@/components/public/Navbar";
import { HeroSection } from "@/components/public/HeroSection";
import { KnowledgeCards } from "@/components/public/KnowledgeCards";
import { AboutSection } from "@/components/public/AboutSection";
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
import { MeetupsSection } from "@/components/public/MeetupsSection";
@@ -11,6 +10,7 @@ import { FAQSection } from "@/components/public/FAQSection";
import { FinalCTASection } from "@/components/public/FinalCTASection";
import { Footer } from "@/components/public/Footer";
import { api } from "@/lib/api";
import { formatMeetupCivilDate, getMeetupStartUtc } from "@/lib/meetupEventTime";
export default function HomePage() {
const [meetup, setMeetup] = useState<any>(null);
@@ -22,10 +22,18 @@ export default function HomePage() {
.then((data: any) => {
const all = Array.isArray(data) ? data : data?.meetups ?? [];
const now = new Date();
// Keep only PUBLISHED events with a future date, sorted closest-first
// Keep only PUBLISHED events with a future start (Brussels wall time → UTC), sorted closest-first
const upcoming = all
.filter((m: any) => m.status === "PUBLISHED" && m.date && new Date(m.date) > now)
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime());
.filter((m: any) => {
if (m.status !== "PUBLISHED" || !m.date) return false;
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start > now;
})
.sort(
(a: any, b: any) =>
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
getMeetupStartUtc(b.date, b.time || "00:00").getTime()
);
setAllMeetups(upcoming);
if (upcoming.length > 0) setMeetup(upcoming[0]);
})
@@ -36,11 +44,14 @@ export default function HomePage() {
.catch(() => {});
}, []);
const featuredCivil = meetup ? formatMeetupCivilDate(meetup.date) : null;
const meetupProps = meetup
? {
id: meetup.id,
month: new Date(meetup.date).toLocaleString("en-US", { month: "short" }),
day: String(new Date(meetup.date).getDate()),
month: featuredCivil
? featuredCivil.monthShort.charAt(0) + featuredCivil.monthShort.slice(1).toLowerCase()
: "TBD",
day: featuredCivil?.day ?? "--",
title: meetup.title,
location: meetup.location,
time: meetup.time,
@@ -57,7 +68,6 @@ export default function HomePage() {
<section id="about">
<AboutSection />
</section>
<KnowledgeCards />
<CommunityLinksSection settings={settings} />
<section id="upcoming-meetups">
<MeetupsSection meetups={allMeetups} />

View File

@@ -6,10 +6,11 @@ import { Footer } from "@/components/public/Footer";
export const metadata: Metadata = {
title: "Privacy Policy",
description:
"Privacy policy for the Belgian Bitcoin Embassy website. We collect minimal data, use no tracking cookies, and respect your sovereignty.",
"GDPR-oriented privacy policy for the Belgian Bitcoin Embassy. Learn what data we process, why we process it, and your rights.",
openGraph: {
title: "Privacy Policy - Belgian Bitcoin Embassy",
description: "How we handle your data. Minimal collection, no tracking, full transparency.",
description:
"How we process data for account access, moderation, and community features, with clear GDPR rights and transparency.",
},
alternates: { canonical: "/privacy" },
};
@@ -20,43 +21,93 @@ export default function PrivacyPage() {
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-8">Privacy Policy</h1>
<h1 className="text-4xl font-black mb-3">Privacy Policy</h1>
<p className="text-sm text-on-surface-variant mb-8">Last updated: April 3, 2026</p>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Overview</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Who We Are</h2>
<p>
The Belgian Bitcoin Embassy values your privacy. This website is designed
to collect as little personal data as possible. We do not use tracking
cookies, analytics services, or advertising networks.
Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education
and meetups in Belgium. We aim to process the minimum data needed to run this
website safely and reliably.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Data We Collect</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">What Data We Process</h2>
<p>
If you log in using a Nostr extension, we store your public key to
identify your session. Public keys are, by nature, public information
on the Nostr network. We do not collect email addresses, names, or
any other personal identifiers.
If you log in with Nostr, we process your public key, role, and optional
username. We also process content needed to operate the site, such as posts,
submissions, media metadata, and moderation records. Some Nostr-related data
may be cached on our servers to improve performance.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Nostr Interactions</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Why We Process Data</h2>
<p>
Likes and comments are published to the Nostr network via your own
extension. These are peer-to-peer actions and are not stored on our
servers beyond local caching for display purposes.
We process data to provide core site features, maintain account sessions,
prevent abuse, moderate community interactions, and keep the service secure.
Our legal bases are contract (or steps requested by you before using features)
and legitimate interests (security, integrity, and service operation).
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Local Storage</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Cookies and Local Storage</h2>
<p>
We use browser local storage to persist your authentication session.
You can clear this at any time by logging out or clearing your
browser data.
We currently do not use third-party analytics or advertising cookies. We do use
browser local storage to keep your authentication session active. You can clear
this data at any time by logging out or clearing browser storage.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Recipients</h2>
<p>
When you interact through Nostr, your actions are published on the Nostr network,
which is public by design. We may also use infrastructure providers to host and
secure the website.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Retention</h2>
<p>
We keep account and operational data only as long as needed for service operation,
security, and moderation. Technical logs may be retained for a limited period.
You can remove local browser data at any time.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Your GDPR Rights</h2>
<p>
Depending on applicable law, you may have rights to access, rectify, erase, restrict,
object to, or request portability of your personal data. You also have the right to
lodge a complaint with the Belgian Data Protection Authority.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">International Transfers</h2>
<p>
If technical providers process data outside the EEA, we aim to rely on appropriate
safeguards as required under GDPR.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Children</h2>
<p>This website is not directed at children under the age of 16.</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Policy Updates</h2>
<p>
We may update this Privacy Policy from time to time. Material changes are reflected
by updating the date at the top of this page.
</p>
</section>

View File

@@ -6,10 +6,11 @@ import { Footer } from "@/components/public/Footer";
export const metadata: Metadata = {
title: "Terms of Use",
description:
"Terms of use for the Belgian Bitcoin Embassy website. Community-driven, non-commercial Bitcoin education platform in Belgium.",
"Terms of use for the Belgian Bitcoin Embassy website, including education-only scope, risk warnings, and user responsibilities.",
openGraph: {
title: "Terms of Use - Belgian Bitcoin Embassy",
description: "Terms governing the use of the Belgian Bitcoin Embassy platform.",
description:
"Terms governing access, risk disclosures, no-investment-advice scope, and liability limits for the Belgian Bitcoin Embassy platform.",
},
alternates: { canonical: "/terms" },
};
@@ -20,34 +21,56 @@ export default function TermsPage() {
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-8">Terms of Use</h1>
<h1 className="text-4xl font-black mb-3">Terms of Use</h1>
<p className="text-sm text-on-surface-variant mb-8">Last updated: April 3, 2026</p>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">About This Site</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Acceptance and Changes</h2>
<p>
The Belgian Bitcoin Embassy website is a community-driven, non-commercial
platform focused on Bitcoin education and meetups in Belgium. By using
this site, you agree to these terms.
By accessing or using this website, you agree to these Terms of Use. We may
update these terms from time to time, and continued use after updates means you
accept the revised terms.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Content</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Nature of the Service</h2>
<p>
Blog content on this site is curated from the Nostr network. The
Belgian Bitcoin Embassy does not claim ownership of third-party
content and provides it for educational purposes only. Content
moderation is applied locally and does not affect the Nostr network.
This website provides general Bitcoin education and community information.
Nothing on this website is financial, investment, legal, or tax advice.
We do not make recommendations to buy, sell, or hold Bitcoin or any other
crypto-asset. Content is general in nature and not tailored to your personal
circumstances.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">No Financial Advice</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Crypto Risk Warning</h2>
<p>
Nothing on this website constitutes financial advice. Bitcoin is a
volatile asset. Always do your own research and consult qualified
professionals before making financial decisions.
Crypto-assets are highly volatile and you can lose all of your money.
Crypto-assets are not regulated in the same way as traditional financial
products. Regulatory rules may change, and availability may differ by
jurisdiction. Always do your own research and consult a qualified professional
before making financial decisions.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">MiCA and Regulatory Position</h2>
<p>
Belgian Bitcoin Embassy presents this website as an educational platform and not
as a crypto-asset service provider. If the nature of our activities changes, we
may update these terms and related legal pages.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Content and Third Parties</h2>
<p>
Some content is curated from the Nostr network. We do not claim ownership of
third-party content. Local moderation may hide or limit content on this site,
but does not change content on the Nostr network itself.
</p>
</section>
@@ -61,11 +84,48 @@ export default function TermsPage() {
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Liability</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Paid and Commercial Features</h2>
<p>
The Belgian Bitcoin Embassy is a community initiative, not a legal
entity. We provide this platform as-is with no warranties. Use at
your own discretion.
Certain features may involve Lightning payments, such as paid public board
messages. Any such feature is optional and does not change the educational
nature of the site.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Affiliate and Sponsorship Transparency</h2>
<p>
As of the last updated date above, we do not earn referral fees from links on
this website. If sponsored or affiliate content is added in the future, it will
be clearly disclosed.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Disclaimer and Liability</h2>
<p>
This platform is provided on an &quot;as is&quot; and &quot;as available&quot; basis without
warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin
Embassy is not liable for losses or damages resulting from your use of this site
or reliance on its content.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Governing Law</h2>
<p>
These terms are governed by Belgian law, without prejudice to mandatory consumer
protections that apply in your jurisdiction.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Contact</h2>
<p>
For terms-related questions, contact us through our{" "}
<Link href="/#community" className="text-primary hover:underline">
community channels
</Link>.
</p>
</section>
</div>

View File

@@ -19,15 +19,19 @@ import {
Inbox,
ImageIcon,
HelpCircle,
MessageSquare,
Building2,
} from "lucide-react";
const navItems = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
{ href: "/admin/organizers", label: "Organizers", icon: Building2, adminOnly: false },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
{ href: "/admin/messages", label: "Board", icon: MessageSquare, adminOnly: false },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },

View File

@@ -0,0 +1,126 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { CalendarPlus, Copy, Check, Download, ExternalLink, X } from "lucide-react";
const siteUrl =
typeof window !== "undefined"
? window.location.origin
: process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
function Dialog({ onClose }: { onClose: () => void }) {
const icsUrl = `${siteUrl}/calendar.ics`;
const webcalUrl = icsUrl.replace(/^https?:\/\//, "webcal://");
const [copied, setCopied] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCopy = async () => {
await navigator.clipboard.writeText(icsUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
className="relative bg-zinc-900 border border-zinc-800 rounded-2xl w-full max-w-md p-6 shadow-2xl"
>
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-on-surface-variant/50 hover:text-on-surface transition-colors"
aria-label="Close"
>
<X size={18} />
</button>
<div className="flex items-center gap-2.5 mb-4">
<div className="bg-primary/10 text-primary rounded-lg p-2">
<CalendarPlus size={20} />
</div>
<h2 className="text-lg font-bold">Add to Calendar</h2>
</div>
<p className="text-sm text-on-surface-variant leading-relaxed mb-6">
Subscribe to this feed to get all public Belgian Bitcoin Embassy
meetups in your calendar. New events are added automatically.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-on-surface-variant/60 mb-1.5">
Calendar URL
</label>
<div className="flex gap-2">
<input
readOnly
value={icsUrl}
className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-on-surface select-all focus:outline-none focus:border-primary/50"
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
onClick={handleCopy}
className="shrink-0 flex items-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2 text-xs font-medium transition-all"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<a
href={webcalUrl}
className="flex items-center justify-center gap-1.5 bg-primary text-on-primary rounded-lg px-3 py-2.5 text-xs font-semibold hover:brightness-110 transition-all"
>
<ExternalLink size={14} />
Open in Calendar
</a>
<a
href="/calendar.ics"
download="bbe-events.ics"
className="flex items-center justify-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2.5 text-xs font-semibold transition-all"
>
<Download size={14} />
Download .ics
</a>
</div>
</div>
</div>
</div>
);
}
export function AddToCalendarButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="Subscribe to get all future meetups automatically"
className={
className ??
"flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
}
>
<CalendarPlus size={14} />
Add to Calendar
</button>
{open && <Dialog onClose={close} />}
</>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Send, Zap, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
const CHANNELS = [
{
key: "telegram_link" as const,
title: "Telegram",
description:
"Join our Telegram group for quick questions and community chat.",
Icon: Send,
},
{
key: "nostr_link" as const,
title: "Nostr",
description: "Follow us on Nostr for censorship-resistant communication.",
Icon: Zap,
},
{
key: "x_link" as const,
title: "X (Twitter)",
description: "Follow us on X for announcements and updates.",
Icon: ExternalLink,
},
];
export function ContactChannelGrid() {
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api
.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{CHANNELS.map(({ key, title, description, Icon }) => {
const href = settings[key] || "#";
const isExternal = href.startsWith("http");
return (
<a
key={key}
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Icon size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">{title}</h2>
<p className="text-on-surface-variant text-sm">{description}</p>
</a>
);
})}
<div className="bg-surface-container-low p-8 rounded-xl">
<h2 className="text-xl font-bold mb-2">Meetups</h2>
<p className="text-on-surface-variant text-sm mb-4">
The best way to connect is in person. Come to our monthly meetup in
Brussels.
</p>
<Link
href="/#meetup"
className="text-primary font-bold text-sm hover:underline"
>
See next meetup
</Link>
</div>
</div>
);
}

View File

@@ -37,6 +37,23 @@ export function Footer() {
<p className="text-white opacity-50 text-xs sm:text-sm tracking-widest uppercase max-w-[min(100%,22rem)] sm:max-w-md leading-relaxed text-balance">
&copy; Belgian Bitcoin Embassy.
</p>
<div className="max-w-3xl text-white/65 text-xs sm:text-sm leading-relaxed text-left">
<h2 className="text-white/75 font-semibold mb-2">Disclaimer</h2>
<p>
The Belgian Bitcoin Embassy provides information for educational purposes only and
does not offer financial, investment, or legal advice. Bitcoin and other
cryptocurrencies are subject to high volatility and potential risks. Always conduct
your own research and consult a qualified professional before making any financial
decisions. The Embassy is not responsible for any losses or damages resulting from the
use of this information.
</p>
<p className="mt-3">
Cryptocurrencies may be subject to regulatory changes; ensure compliance with local
laws. Remember to practice safe security measures, including the use of secure wallets
and private key protection.
</p>
</div>
</div>
</footer>
);

View File

@@ -106,6 +106,8 @@ interface EventJsonLdProps {
location?: string;
url: string;
imageUrl?: string;
organizerName?: string;
organizerUrl?: string;
}
export function EventJsonLd({
@@ -115,7 +117,11 @@ export function EventJsonLd({
location,
url,
imageUrl,
organizerName,
organizerUrl,
}: EventJsonLdProps) {
const orgName = organizerName || "Belgian Bitcoin Embassy";
const orgUrl = organizerUrl || siteUrl;
return (
<JsonLd
data={{
@@ -141,8 +147,8 @@ export function EventJsonLd({
: {}),
organizer: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
name: orgName,
url: orgUrl,
},
image:
imageUrl || `${siteUrl}/og?title=${encodeURIComponent(name)}&type=event`,

View File

@@ -1,49 +0,0 @@
import { Landmark, Infinity, Key } from "lucide-react";
const CARDS = [
{
icon: Landmark,
title: "Money without banks",
description:
"Operate outside the legacy financial system with peer-to-peer digital sound money.",
},
{
icon: Infinity,
title: "Scarcity: 21 million",
description:
"A mathematical certainty of fixed supply. No inflation, no dilution, ever.",
},
{
icon: Key,
title: "Self-custody",
description:
"True ownership. Your keys, your bitcoin. No counterparty risk, absolute freedom.",
},
];
export function KnowledgeCards() {
return (
<section className="py-16 px-8 border-t border-zinc-800/50">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{CARDS.map((card) => (
<div
key={card.title}
className="flex gap-4 p-6 rounded-xl bg-zinc-900/60 border border-zinc-800/60"
>
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<card.icon size={16} className="text-primary" />
</div>
<div>
<h4 className="font-bold mb-1.5 text-sm">{card.title}</h4>
<p className="text-on-surface-variant text-sm leading-relaxed">
{card.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,67 @@
import Link from "next/link";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
export function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
const civil = formatMeetupCivilDate(meetup.date);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
const full = civil?.full ?? "";
return (
<Link
href={`/events/${meetup.id}`}
className={`group flex flex-col bg-zinc-900 border rounded-xl p-6 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 ${
muted
? "border-zinc-800/60 opacity-70 hover:opacity-100 hover:border-zinc-700"
: "border-zinc-800 hover:border-zinc-700"
}`}
>
<div className="flex items-start gap-4 mb-4">
<div className={`rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px] ${muted ? "bg-zinc-800/60" : "bg-zinc-800"}`}>
<span className={`block text-[10px] font-bold uppercase tracking-wider leading-none mb-0.5 ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="min-w-0">
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
{meetup.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
</div>
</div>
{meetup.description && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{meetup.description}
</p>
)}
<p className="text-[11px] text-on-surface-variant/50 font-medium uppercase tracking-wide mb-2">
Organized by{" "}
<span className="text-on-surface-variant/70 normal-case tracking-normal">
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</p>
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.location}
</p>
)}
{meetup.time && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<Clock size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.time}
</p>
)}
</div>
<span className={`flex items-center gap-1.5 text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
View Details <ArrowRight size={12} />
</span>
</Link>
);
}

View File

@@ -1,5 +1,7 @@
import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
interface MeetupData {
id?: string;
@@ -9,21 +11,13 @@ interface MeetupData {
location?: string;
link?: string;
description?: string;
organizer?: { name: string; slug?: string };
}
interface MeetupsSectionProps {
meetups: MeetupData[];
}
function formatMeetupDate(dateStr: string) {
const d = new Date(dateStr);
return {
month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
day: String(d.getDate()),
full: d.toLocaleString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
};
}
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
@@ -36,14 +30,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
<h2 className="text-3xl font-black tracking-tight">Upcoming Meetups</h2>
</div>
<div className="hidden md:flex items-center gap-4">
<a
href="/calendar.ics"
title="Subscribe to get all future meetups automatically"
className="flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
>
<CalendarPlus size={14} />
Add to Calendar
</a>
<AddToCalendarButton />
<Link
href="/events"
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
@@ -62,7 +49,10 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{meetups.map((meetup, i) => {
const { month, day, full } = formatMeetupDate(meetup.date);
const civil = formatMeetupCivilDate(meetup.date);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
const full = civil?.full ?? "";
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
@@ -92,6 +82,13 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
</p>
)}
<p className="text-[11px] text-on-surface-variant/50 font-medium uppercase tracking-wide mb-2">
Organized by{" "}
<span className="text-on-surface-variant/70 normal-case tracking-normal">
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</p>
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
@@ -123,13 +120,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
>
All events <ArrowRight size={16} />
</Link>
<a
href="/calendar.ics"
className="flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
>
<CalendarPlus size={14} />
Add to Calendar
</a>
<AddToCalendarButton />
</div>
</div>
</section>

View File

@@ -14,6 +14,7 @@ const SECTION_LINKS = [{ label: "About", anchor: "about" }];
const PAGE_LINKS = [
{ label: "Meetups", href: "/events" },
{ label: "Board", href: "/board" },
{ label: "Community", href: "/community" },
{ label: "FAQ", href: "/faq" },
];

View File

@@ -0,0 +1,80 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { MeetupCard } from "@/components/public/MeetupCard";
export function UpcomingEventsCarousel({ excludeId }: { excludeId: string }) {
const [items, setItems] = useState<any[]>([]);
const scrollerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api
.getMeetups()
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
const now = new Date();
const upcoming = list
.filter((m: any) => {
if (m.id === excludeId) return false;
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start >= now;
})
.sort(
(a: any, b: any) =>
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
getMeetupStartUtc(b.date, b.time || "00:00").getTime()
);
setItems(upcoming);
})
.catch(() => setItems([]));
}, [excludeId]);
const scrollByDir = (dir: -1 | 1) => {
const el = scrollerRef.current;
if (!el) return;
const delta = Math.min(el.clientWidth * 0.85, 360);
el.scrollBy({ left: dir * delta, behavior: "smooth" });
};
if (items.length === 0) return null;
return (
<section className="mt-16 pt-12 border-t border-zinc-800/50">
<div className="flex items-end justify-between gap-4 mb-6">
<h2 className="text-lg font-black tracking-tight">More upcoming events</h2>
<div className="flex gap-2 shrink-0">
<button
type="button"
onClick={() => scrollByDir(-1)}
className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
aria-label="Scroll left"
>
<ChevronLeft size={18} />
</button>
<button
type="button"
onClick={() => scrollByDir(1)}
className="p-2.5 rounded-xl border border-zinc-800 bg-zinc-900/80 text-on-surface-variant hover:text-primary hover:border-zinc-700 transition-colors"
aria-label="Scroll right"
>
<ChevronRight size={18} />
</button>
</div>
</div>
<div
ref={scrollerRef}
className="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 -mx-1 px-1 [scrollbar-width:thin]"
>
{items.map((m) => (
<div key={m.id} className="snap-start shrink-0 w-[min(280px,calc(100vw-5rem))] sm:w-[280px]">
<MeetupCard meetup={m} />
</div>
))}
</div>
</section>
);
}

View File

@@ -51,10 +51,11 @@ export const api = {
request<void>(`/posts/${id}`, { method: "DELETE" }),
// Meetups
getMeetups: (params?: { status?: string; admin?: boolean }) => {
getMeetups: (params?: { status?: string; admin?: boolean; organizerSlug?: string }) => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.admin) searchParams.set("admin", "true");
if (params?.organizerSlug) searchParams.set("organizerSlug", params.organizerSlug);
const qs = searchParams.toString();
return request<any[]>(`/meetups${qs ? `?${qs}` : ""}`);
},
@@ -103,6 +104,17 @@ export const api = {
deleteCategory: (id: string) =>
request<void>(`/categories/${id}`, { method: "DELETE" }),
// Organizers
getOrganizers: () => request<any[]>("/organizers"),
getOrganizerBySlug: (slug: string) =>
request<any>(`/organizers/by-slug/${encodeURIComponent(slug)}`),
createOrganizer: (data: { name: string; slug: string }) =>
request<any>("/organizers", { method: "POST", body: JSON.stringify(data) }),
updateOrganizer: (id: string, data: { name?: string; slug?: string }) =>
request<any>(`/organizers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteOrganizer: (id: string) =>
request<void>(`/organizers/${id}`, { method: "DELETE" }),
// Relays
getRelays: () => request<any[]>("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
@@ -183,4 +195,51 @@ export const api = {
},
reviewSubmission: (id: string, data: { status: string; reviewNote?: string }) =>
request<any>(`/submissions/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
// Message board (Lightning)
getBoardConfig: () =>
request<{ priceSats: number; bbeZapPubkey: string | null; bbeZapAddress: string | null }>(
"/messages/config"
),
createBoardInvoice: (data: {
content: string;
name?: string;
pubkey?: string;
profilePic?: string;
postAsAnon?: boolean;
}) =>
request<{ payment_request: string; checking_id: string; payment_hash: string }>(
"/messages/invoice",
{ method: "POST", body: JSON.stringify(data) }
),
getBoardPaymentStatus: (paymentHash: string) =>
request<{ paid: boolean; messageCreated: boolean }>(
`/messages/payment/${encodeURIComponent(paymentHash)}/status`
),
confirmBoardPayment: (paymentHash: string) =>
request<{ ok: boolean; duplicate?: boolean; ignored?: string }>(
`/messages/confirm/${encodeURIComponent(paymentHash)}`,
{ method: "POST" }
),
getBoardMessages: () =>
request<
Array<{
id: string;
paymentHash: string;
content: string;
authorName: string;
pubkey: string | null;
profilePic: string | null;
satsPaid: number;
likeCount: number;
createdAt: string;
}>
>("/messages"),
likeBoardMessage: (id: string) =>
request<{ likeCount: number }>(`/messages/${id}/like`, { method: "POST" }),
getAdminBoardMessages: () => request<any[]>("/admin/messages"),
hideBoardMessage: (id: string) =>
request<any>(`/admin/messages/${id}/hide`, { method: "POST" }),
deleteBoardMessage: (id: string) =>
request<any>(`/admin/messages/${id}`, { method: "DELETE" }),
};

View File

@@ -51,3 +51,44 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
const utcStartH = startH - BRUSSELS_OFFSET_HOURS;
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
}
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
/**
* Format the stored meetup calendar date (YYYY-MM-DD) for display without shifting
* by the viewer's timezone. Date-only strings parsed as new Date("YYYY-MM-DD") are
* UTC midnight and show the wrong local day in western zones.
*/
export function formatMeetupCivilDate(dateStr: string): {
monthShort: string;
day: string;
full: string;
} | null {
const key = normalizeMeetupDateKey(dateStr);
if (!key) return null;
const parts = key.split("-").map(Number);
const year = parts[0];
const month = parts[1];
const dayNum = parts[2];
if (!year || !month || !dayNum) return null;
const ref = new Date(Date.UTC(year, month - 1, dayNum, 12, 0, 0));
return {
monthShort: ref
.toLocaleString("en-US", { ...UTC_CAL_OPTS, month: "short" })
.toUpperCase(),
day: String(ref.getUTCDate()),
full: ref.toLocaleString("en-US", {
...UTC_CAL_OPTS,
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
};
}
/** Long single-line civil date for event detail (same rules as formatMeetupCivilDate). */
export function formatMeetupCivilDateLong(dateStr: string): string {
return formatMeetupCivilDate(dateStr)?.full ?? "—";
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "belgian-bitcoin-embassy",
"private": true,
"scripts": {
"db:push": "npm run db:push --prefix backend",
"db:seed": "npm run db:seed --prefix backend",
"db:studio": "npm run db:studio --prefix backend"
}
}