Files
BelgianBitcoinEmbassy/frontend/app/board/page.tsx
bbe 586b572f73 feat(board): Lightning-paid message board with LNbits and admin moderation
Add public /board flow: create invoice, webhook + confirm reconciliation, list
active messages, likes (Nostr), zap fallbacks. Admin table for hide/delete.

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

Made-with: Cursor
2026-04-03 18:37:52 +02:00

450 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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