Compare commits
2 Commits
7acff1ae38
...
78271ea110
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78271ea110 | ||
|
|
586b572f73 |
25
.env.example
25
.env.example
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
18
backend/scripts/baseline-and-migrate.sh
Executable file
18
backend/scripts/baseline-and-migrate.sh
Executable 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
|
||||
68
backend/src/api/adminMessages.ts
Normal file
68
backend/src/api/adminMessages.ts
Normal 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;
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
359
backend/src/api/messages.ts
Normal 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;
|
||||
132
backend/src/api/organizers.ts
Normal file
132
backend/src/api/organizers.ts
Normal 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;
|
||||
2
backend/src/constants/organizer.ts
Normal file
2
backend/src/constants/organizer.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Slug for the default organizer row (seed + migration). */
|
||||
export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';
|
||||
@@ -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' });
|
||||
|
||||
36
backend/src/lib/prismaMigrationHint.ts
Normal file
36
backend/src/lib/prismaMigrationHint.ts
Normal 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;
|
||||
}
|
||||
113
backend/src/services/lnbits.ts
Normal file
113
backend/src/services/lnbits.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
146
frontend/app/admin/messages/page.tsx
Normal file
146
frontend/app/admin/messages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
frontend/app/admin/organizers/page.tsx
Normal file
186
frontend/app/admin/organizers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
16
frontend/app/board/layout.tsx
Normal file
16
frontend/app/board/layout.tsx
Normal 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
449
frontend/app/board/page.tsx
Normal 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="What’s 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={[
|
||||
|
||||
153
frontend/app/events/organizer/[slug]/OrganizerEventsClient.tsx
Normal file
153
frontend/app/events/organizer/[slug]/OrganizerEventsClient.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
frontend/app/events/organizer/[slug]/page.tsx
Normal file
42
frontend/app/events/organizer/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 "as is" and "as available" 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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
126
frontend/components/public/AddToCalendarDialog.tsx
Normal file
126
frontend/components/public/AddToCalendarDialog.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
frontend/components/public/ContactChannelGrid.tsx
Normal file
75
frontend/components/public/ContactChannelGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© 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>
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
67
frontend/components/public/MeetupCard.tsx
Normal file
67
frontend/components/public/MeetupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
80
frontend/components/public/UpcomingEventsCarousel.tsx
Normal file
80
frontend/components/public/UpcomingEventsCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
@@ -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
9
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user