first commit

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-26 18:33:00 -03:00
commit 3734365463
76 changed files with 14133 additions and 0 deletions

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Backend API URL (required in dev when frontend runs on different port)
# Leave empty if frontend is served from same origin as API
VITE_API_URL=http://localhost:3001

View File

@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Free Bitcoins</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: #fff;
color: #333;
}
.topbar {
background: #1a1a1a;
height: 6px;
width: 100%;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
display: flex;
gap: 30px;
}
.sidebar {
width: 140px;
flex-shrink: 0;
padding-top: 10px;
}
.sidebar p {
font-size: 13px;
margin-bottom: 8px;
color: #555;
}
.sidebar .label {
font-size: 13px;
color: #555;
margin-top: 20px;
margin-bottom: 4px;
}
.sidebar a {
display: block;
color: #0645ad;
font-size: 14px;
font-weight: bold;
text-decoration: none;
margin-bottom: 4px;
}
.sidebar a:hover { text-decoration: underline; }
.main { flex: 1; }
.header {
display: flex;
align-items: center;
margin-bottom: 30px;
border-bottom: 2px solid #eee;
padding-bottom: 16px;
}
.faucet-svg {
width: 110px;
height: 150px;
}
h1 {
font-size: 2.8rem;
font-weight: normal;
color: #bbb;
letter-spacing: 1px;
}
.content h2 {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 12px;
color: #222;
}
.content p {
font-size: 14px;
margin-bottom: 16px;
color: #444;
line-height: 1.5;
}
.turnstile-wrap {
margin-bottom: 16px;
}
/* ── Entropy Generator ── */
.entropy-box {
display: inline-flex;
align-items: center;
gap: 14px;
background: #f7f7f7;
border: 1px solid #ddd;
border-radius: 6px;
padding: 10px 16px;
margin-bottom: 16px;
}
.entropy-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.entropy-display {
font-size: 2rem;
font-weight: bold;
color: #f39c12;
min-width: 32px;
text-align: center;
font-family: monospace;
transition: color 0.15s;
}
.entropy-display.rolling {
color: #ccc;
}
.entropy-unit {
font-size: 12px;
color: #888;
}
.entropy-bar {
display: flex;
gap: 4px;
align-items: center;
}
.entropy-pip {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ddd;
transition: background 0.2s;
}
.entropy-pip.active {
background: #f39c12;
}
.entropy-btn {
font-size: 11px;
background: #fff;
border: 1px solid #bbb;
border-radius: 3px;
padding: 3px 8px;
cursor: pointer;
color: #555;
}
.entropy-btn:hover { background: #f0f0f0; }
.entropy-source {
font-size: 10px;
color: #bbb;
font-style: italic;
}
/* Address row */
.address-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.address-row label {
font-size: 14px;
color: #444;
}
.address-row input[type="text"] {
width: 220px;
height: 26px;
border: 1px solid #aaa;
padding: 2px 6px;
font-size: 13px;
}
.address-row button {
height: 26px;
padding: 0 14px;
font-size: 13px;
background: #f0f0f0;
border: 1px solid #aaa;
cursor: pointer;
border-radius: 2px;
}
.address-row button:hover { background: #e0e0e0; }
</style>
</head>
<body>
<div class="topbar"></div>
<div class="container">
<div class="sidebar">
<p>Sats available</p>
<p class="label">Other Sites:</p>
<a href="https://bitcoin.org/en/">Bitcoin.org</a>
<a href="https://bitcoin.org/en/buy">Bitcoin Market</a>
</div>
<div class="main">
<div class="header">
<svg class="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="52" cy="14" rx="10" ry="6" fill="#c0392b"/>
<rect x="49" y="14" width="6" height="16" fill="#c0392b"/>
<rect x="30" y="28" width="44" height="30" rx="6" fill="#c0392b"/>
<rect x="72" y="36" width="24" height="14" rx="4" fill="#c0392b"/>
<rect x="85" y="50" width="12" height="32" rx="4" fill="#c0392b"/>
<ellipse cx="89" cy="91" rx="3" ry="5" fill="#5dade2" opacity="0.85"/>
<ellipse cx="92" cy="102" rx="2" ry="4" fill="#5dade2" opacity="0.65"/>
<ellipse cx="87" cy="108" rx="2" ry="3.5" fill="#5dade2" opacity="0.5"/>
<ellipse cx="91" cy="116" rx="1.5" ry="3" fill="#5dade2" opacity="0.35"/>
<rect x="10" y="33" width="22" height="18" rx="3" fill="#c0392b"/>
<rect x="6" y="36" width="8" height="12" rx="2" fill="#922b21"/>
</svg>
<h1>Free Bitcoins</h1>
</div>
<div class="content">
<h2>Get Bitcoins from the Bitcoin Faucet</h2>
<p>I'm giving away 1 to 5 satoshis per visitor; just solve the "captcha" then enter your Lightning address and press Get Some:</p>
<!-- Entropy Generator -->
<div class="entropy-box">
<div>
<div class="entropy-label">You will receive</div>
<div style="display:flex; align-items:baseline; gap:6px;">
<div class="entropy-display" id="entropy-display">?</div>
<div class="entropy-unit">sats</div>
</div>
<div class="entropy-source" id="entropy-source">pending roll…</div>
</div>
<div>
<div class="entropy-bar" id="entropy-bar">
<div class="entropy-pip" id="pip-1"></div>
<div class="entropy-pip" id="pip-2"></div>
<div class="entropy-pip" id="pip-3"></div>
<div class="entropy-pip" id="pip-4"></div>
<div class="entropy-pip" id="pip-5"></div>
</div>
<div style="margin-top:8px;">
<button class="entropy-btn" onclick="rollEntropy()">&#x21bb; Re-roll</button>
</div>
</div>
</div>
<!-- Cloudflare Turnstile CAPTCHA -->
<div class="turnstile-wrap">
<div class="cf-turnstile"
data-sitekey="0x4AAAAAAChmQ1hiZcL5Tf1s"
data-callback="onTurnstileSuccess">
</div>
</div>
<!-- Lightning Address -->
<div class="address-row">
<label>Your Lightning Address:</label>
<input type="text" id="lightning-address" placeholder="you@wallet.com">
<button onclick="handleGetSome()">Get Some!</button>
</div>
</div>
</div>
</div>
<script>
let turnstileToken = null;
let satAmount = null;
function onTurnstileSuccess(token) {
turnstileToken = token;
}
// Uses crypto.getRandomValues for cryptographic-quality entropy
function cryptoRandInt(min, max) {
const range = max - min + 1;
const buf = new Uint32Array(1);
let result;
// Rejection sampling to avoid modulo bias
do {
crypto.getRandomValues(buf);
result = buf[0] % range;
} while (buf[0] > Math.floor(0xFFFFFFFF / range) * range);
return min + result;
}
function rollEntropy() {
const display = document.getElementById('entropy-display');
const source = document.getElementById('entropy-source');
satAmount = null;
// Animate a short roll
let ticks = 0;
const totalTicks = 14;
display.classList.add('rolling');
const interval = setInterval(() => {
const temp = cryptoRandInt(1, 5);
display.textContent = temp;
updatePips(temp);
ticks++;
if (ticks >= totalTicks) {
clearInterval(interval);
// Final authoritative roll
satAmount = cryptoRandInt(1, 5);
// Collect extra entropy from timing jitter
const jitterBuf = new Uint32Array(4);
crypto.getRandomValues(jitterBuf);
const entropyHex = Array.from(jitterBuf).map(n => n.toString(16).padStart(8,'0')).join('').slice(0, 12);
display.textContent = satAmount;
display.classList.remove('rolling');
updatePips(satAmount);
source.textContent = 'entropy: 0x' + entropyHex + '…';
}
}, 60);
}
function updatePips(n) {
for (let i = 1; i <= 5; i++) {
const pip = document.getElementById('pip-' + i);
pip.classList.toggle('active', i <= n);
}
}
// Roll on page load
window.addEventListener('DOMContentLoaded', rollEntropy);
function handleGetSome() {
const address = document.getElementById('lightning-address').value.trim();
if (satAmount === null) {
alert('Please wait for the entropy roll to complete.');
return;
}
if (!turnstileToken) {
alert('Please complete the CAPTCHA first.');
return;
}
if (!address) {
alert('Please enter your Lightning address.');
return;
}
console.log('Sats to send:', satAmount);
console.log('Turnstile token:', turnstileToken);
console.log('Lightning address:', address);
alert('Request submitted! Sending ' + satAmount + ' sat(s) to ' + address);
}
</script>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sats Faucet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2297
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "lnfaucet-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"framer-motion": "^11.11.17",
"nostr-tools": "^2.4.4",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

101
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { getToken, getAuthMe, clearToken } from "./api";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
import { ClaimWizard } from "./components/ClaimWizard";
import { StatsSection } from "./components/StatsSection";
import { DepositSection } from "./components/DepositSection";
import { TransactionsPage } from "./pages/TransactionsPage";
const FaucetSvg = () => (
<svg className="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="52" cy="14" rx="10" ry="6" fill="#c0392b" />
<rect x="49" y="14" width="6" height="16" fill="#c0392b" />
<rect x="30" y="28" width="44" height="30" rx="6" fill="#c0392b" />
<rect x="72" y="36" width="24" height="14" rx="4" fill="#c0392b" />
<rect x="85" y="50" width="12" height="32" rx="4" fill="#c0392b" />
<ellipse cx="89" cy="91" rx="3" ry="5" fill="#5dade2" opacity="0.85" />
<ellipse cx="92" cy="102" rx="2" ry="4" fill="#5dade2" opacity="0.65" />
<ellipse cx="87" cy="108" rx="2" ry="3.5" fill="#5dade2" opacity="0.5" />
<ellipse cx="91" cy="116" rx="1.5" ry="3" fill="#5dade2" opacity="0.35" />
<rect x="10" y="33" width="22" height="18" rx="3" fill="#c0392b" />
<rect x="6" y="36" width="8" height="12" rx="2" fill="#922b21" />
</svg>
);
export default function App() {
const [pubkey, setPubkey] = useState<string | null>(null);
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
useEffect(() => {
const token = getToken();
if (!token) return;
getAuthMe()
.then((r) => setPubkey(r.pubkey))
.catch(() => {
clearToken();
setPubkey(null);
});
}, []);
const handleClaimSuccess = useCallback(() => {
setStatsRefetchTrigger((t) => t + 1);
}, []);
return (
<BrowserRouter>
<div className="app">
<Header />
<div className="topbar" />
<div className="app-body">
<Routes>
<Route
path="/"
element={
<div className="container">
<aside className="sidebar sidebar-left">
<div className="funding-panel">
<DepositSection />
</div>
<div className="sidebar-links">
<p className="sidebar-links-title">Sats available</p>
<p className="label">Other Sites:</p>
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">
Bitcoin.org
</a>
<a href="https://bitcoin.org/en/buy" target="_blank" rel="noopener noreferrer">
Bitcoin Market
</a>
</div>
</aside>
<main className="main">
<div className="header">
<FaucetSvg />
<h1>Sats Faucet</h1>
</div>
<ClaimWizard pubkey={pubkey} onPubkeyChange={setPubkey} onClaimSuccess={handleClaimSuccess} />
</main>
<aside className="sidebar sidebar-right">
<StatsSection refetchTrigger={statsRefetchTrigger} />
</aside>
</div>
}
/>
<Route
path="/transactions"
element={
<div className="container container--single">
<main className="main main--full">
<TransactionsPage />
</main>
</div>
}
/>
</Routes>
</div>
<Footer />
</div>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,43 @@
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("App error:", error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
return (
<div style={{ padding: "2rem", maxWidth: 900, margin: "0 auto", fontFamily: "Arial", color: "#333" }}>
<h1 style={{ color: "#c0392b", marginBottom: "1rem" }}>Something went wrong</h1>
<pre style={{ background: "#f7f7f7", padding: "1rem", overflow: "auto", fontSize: 13 }}>
{this.state.error.message}
</pre>
<button
type="button"
onClick={() => this.setState({ hasError: false, error: null })}
style={{ marginTop: "1rem", padding: "8px 16px", cursor: "pointer" }}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

310
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,310 @@
import { nip98 } from "nostr-tools";
const API_BASE = (import.meta.env.VITE_API_URL as string) || "";
const TOKEN_KEY = "lnfaucet_token";
/** Build full request URL: no double slashes, works with empty or trailing-slash API_BASE. */
function apiUrl(path: string): string {
if (path.startsWith("http")) return path;
const base = (API_BASE || "").replace(/\/$/, "");
const p = path.startsWith("/") ? path : `/${path}`;
return base ? `${base}${p}` : p;
}
/** Absolute URL for NIP-98 signing (must match what the server sees). */
function absoluteApiUrl(path: string): string {
const relative = apiUrl(path);
return relative.startsWith("http") ? relative : `${window.location.origin}${relative}`;
}
function getLoginUrl(): string {
const base = API_BASE.startsWith("http") ? API_BASE : `${window.location.origin}${API_BASE}`;
return `${base.replace(/\/$/, "")}/auth/login`;
}
export function getToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export function setToken(token: string): void {
sessionStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
sessionStorage.removeItem(TOKEN_KEY);
}
export interface ApiError {
code: string;
message: string;
details?: string;
next_eligible_at?: number;
}
export type DepositSource = "lightning" | "cashu";
export interface Stats {
balanceSats: number;
totalPaidSats: number;
totalClaims: number;
claimsLast24h: number;
dailyBudgetSats: number;
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
}
export interface DepositInfo {
lightningAddress: string;
lnurlp: string;
}
export interface QuoteResult {
quote_id: string;
payout_sats: number;
expires_at: number;
}
export interface ConfirmResult {
success: boolean;
already_consumed?: boolean;
payout_sats?: number;
next_eligible_at?: number;
message?: string;
/** Optional; shown in success UI when backend includes it */
payment_hash?: string;
}
export interface FaucetConfig {
faucetEnabled: boolean;
emergencyStop: boolean;
cooldownDays: number;
minAccountAgeDays: number;
minActivityScore: number;
faucetMinSats: number;
faucetMaxSats: number;
}
export interface UserProfile {
lightning_address: string | null;
name: string | null;
}
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: { kind: number; created_at: number; tags: string[][]; content: string }): Promise<{ id: string; sig: string }>;
};
}
}
async function getNip98Header(method: string, absoluteUrl: string, body?: string): Promise<string> {
const nostr = window.nostr;
if (!nostr) throw new Error("Nostr extension (NIP-07) not found. Install a Nostr wallet extension.");
const pubkey = await nostr.getPublicKey();
const u = absoluteUrl.startsWith("http") ? absoluteUrl : `${window.location.origin}${absoluteUrl}`;
const created_at = Math.floor(Date.now() / 1000);
const event = {
kind: 27235,
created_at,
tags: [
["u", u],
["method", method],
],
content: "",
};
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
const payloadHex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
event.tags.push(["payload", payloadHex]);
}
const signed = await nostr.signEvent(event);
const fullEvent = { ...event, id: signed.id, pubkey, sig: signed.sig };
return "Nostr " + btoa(JSON.stringify(fullEvent));
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = apiUrl(path);
const res = await fetch(url, {
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
next_eligible_at: data.next_eligible_at,
};
throw err;
}
return data as T;
}
async function requestWithNip98<T>(method: string, path: string, body?: object): Promise<T> {
const url = apiUrl(path);
const absoluteUrl = absoluteApiUrl(path);
const bodyStr = body ? JSON.stringify(body) : undefined;
const auth = await getNip98Header(method, absoluteUrl, bodyStr);
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: auth,
},
body: bodyStr,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
next_eligible_at: data.next_eligible_at,
};
throw err;
}
return data as T;
}
async function requestWithBearer<T>(method: string, path: string, body?: object): Promise<T> {
const token = getToken();
if (!token) throw new Error("Not logged in");
const url = apiUrl(path);
const bodyStr = body ? JSON.stringify(body) : undefined;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: bodyStr,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
next_eligible_at: data.next_eligible_at,
};
throw err;
}
return data as T;
}
/** Signer: given an event template, returns the signed event (e.g. extension, bunker, or local nsec). */
export type Nip98Signer = (e: { kind: number; tags: string[][]; content: string; created_at: number }) => Promise<{ id: string; sig: string; pubkey: string; kind: number; tags: string[][]; content: string; created_at: number }>;
/** Login with NIP-98 using a custom signer (extension, remote signer, or nsec). Returns token and pubkey. */
export async function postAuthLoginWithSigner(sign: Nip98Signer): Promise<{ token: string; pubkey: string }> {
const loginUrl = getLoginUrl();
const authValue = await nip98.getToken(loginUrl, "post", sign, true, {});
const url = apiUrl("/auth/login");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authValue },
body: "{}",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err: ApiError = {
code: data.code ?? "request_failed",
message: data.message ?? res.statusText,
details: data.details,
};
throw err;
}
return data as { token: string; pubkey: string };
}
/** Login with NIP-98 (sign once) via extension; returns token. Store with setToken(). */
export async function postAuthLogin(): Promise<{ token: string; pubkey: string }> {
return requestWithNip98<{ token: string; pubkey: string }>("POST", "/auth/login", {});
}
/** Get current user from session (Bearer). */
export async function getAuthMe(): Promise<{ pubkey: string }> {
const token = getToken();
if (!token) throw new Error("Not logged in");
const url = apiUrl("/auth/me");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message ?? "Session invalid");
return data as { pubkey: string };
}
export async function getConfig(): Promise<FaucetConfig> {
return request<FaucetConfig>("/config");
}
export async function getStats(): Promise<Stats> {
return request<Stats>("/stats");
}
export async function getDeposit(): Promise<DepositInfo> {
return request<DepositInfo>("/deposit");
}
export interface CashuRedeemResult {
success: boolean;
paid?: boolean;
amount?: number;
invoiceAmount?: number;
netAmount?: number;
to?: string;
message?: string;
}
export interface CashuRedeemError {
success: false;
error: string;
errorType?: string;
}
export async function postRedeemCashu(token: string): Promise<CashuRedeemResult> {
const res = await fetch(apiUrl("/deposit/redeem-cashu"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: token.trim() }),
});
const data = (await res.json().catch(() => ({}))) as CashuRedeemResult | CashuRedeemError;
if (!res.ok) {
const err = data as CashuRedeemError;
throw new Error(err.error ?? `Redeem failed: ${res.status}`);
}
if (!(data as CashuRedeemResult).success) {
throw new Error((data as CashuRedeemError).error ?? "Redeem failed");
}
return data as CashuRedeemResult;
}
export async function postClaimQuote(lightningAddress: string): Promise<QuoteResult> {
const body = { lightning_address: lightningAddress.trim() };
if (getToken()) return requestWithBearer<QuoteResult>("POST", "/claim/quote", body);
return requestWithNip98<QuoteResult>("POST", "/claim/quote", body);
}
export async function postClaimConfirm(quoteId: string): Promise<ConfirmResult> {
const body = { quote_id: String(quoteId).trim() };
if (getToken()) return requestWithBearer<ConfirmResult>("POST", "/claim/confirm", body);
return requestWithNip98<ConfirmResult>("POST", "/claim/confirm", body);
}
export async function postUserRefreshProfile(): Promise<UserProfile> {
if (getToken()) return requestWithBearer<UserProfile>("POST", "/user/refresh-profile", {});
return requestWithNip98<UserProfile>("POST", "/user/refresh-profile", {});
}
export function hasNostr(): boolean {
return Boolean(typeof window !== "undefined" && window.nostr);
}

View File

@@ -0,0 +1,24 @@
import type { DenialState } from "../hooks/useClaimFlow";
interface ClaimDenialCardProps {
denial: DenialState;
onDismiss?: () => void;
}
export function ClaimDenialCard({ denial, onDismiss }: ClaimDenialCardProps) {
return (
<div className="claim-denial-card">
<p className="claim-denial-message">{denial.message}</p>
{denial.next_eligible_at != null && (
<p className="claim-denial-next">
Next eligible: {new Date(denial.next_eligible_at * 1000).toLocaleString()}
</p>
)}
{onDismiss && (
<button type="button" className="btn-secondary claim-denial-dismiss" onClick={onDismiss}>
Dismiss
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { Countdown } from "./Countdown";
import type { DenialState } from "../hooks/useClaimFlow";
const DENIAL_CODE_EXPLANATIONS: Record<string, string> = {
cooldown_pubkey: "You've already claimed recently. Each pubkey has a cooldown period.",
cooldown_ip: "This IP has reached the claim limit for the cooldown period.",
account_too_new: "Your Nostr account is too new. The faucet requires a minimum account age.",
low_activity: "Your Nostr profile doesn't meet the minimum activity score (notes, following).",
invalid_nip98: "Nostr signature verification failed.",
invalid_lightning_address: "The Lightning address format is invalid or could not be resolved.",
quote_expired: "The quote expired before confirmation.",
payout_failed: "The Lightning payment failed. You can try again.",
faucet_disabled: "The faucet is temporarily disabled.",
emergency_stop: "The faucet is in emergency stop mode.",
insufficient_balance: "The faucet pool has insufficient balance.",
daily_budget_exceeded: "The daily payout budget has been reached.",
};
interface ClaimDenialPanelProps {
denial: DenialState;
onDismiss?: () => void;
/** When provided, shows a "Check again" button (e.g. in wizard step 2) */
onCheckAgain?: () => void;
}
export function ClaimDenialPanel({ denial, onDismiss, onCheckAgain }: ClaimDenialPanelProps) {
const [whyExpanded, setWhyExpanded] = useState(false);
const explanation = denial.code ? DENIAL_CODE_EXPLANATIONS[denial.code] ?? null : null;
return (
<div className="claim-denial-panel">
<div className="claim-denial-panel-icon" aria-hidden>
<span className="claim-denial-panel-icon-inner"></span>
</div>
<h3 className="claim-denial-panel-title">Not eligible yet</h3>
<p className="claim-denial-panel-message">{denial.message}</p>
{denial.next_eligible_at != null && (
<p className="claim-denial-panel-countdown">
Next claim in: <Countdown targetUnixSeconds={denial.next_eligible_at} format="duration" />
</p>
)}
{(denial.code || explanation) && (
<div className="claim-denial-panel-why">
<button
type="button"
className="claim-denial-panel-why-trigger"
onClick={() => setWhyExpanded((e) => !e)}
aria-expanded={whyExpanded}
>
Why?
</button>
{whyExpanded && (
<div className="claim-denial-panel-why-content">
{denial.code && <p className="claim-denial-panel-why-code">Code: {denial.code}</p>}
{explanation && <p className="claim-denial-panel-why-text">{explanation}</p>}
</div>
)}
</div>
)}
<div className="claim-denial-panel-actions">
{onCheckAgain != null && (
<button type="button" className="btn-primary claim-denial-panel-check-again" onClick={onCheckAgain}>
Check again
</button>
)}
{onDismiss && (
<button type="button" className="btn-secondary claim-denial-panel-dismiss" onClick={onDismiss}>
Dismiss
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect, useRef } from "react";
import {
getConfig,
postUserRefreshProfile,
type UserProfile,
type FaucetConfig,
} from "../api";
import { useClaimFlow, ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import { ConnectNostr } from "./ConnectNostr";
import { Modal } from "./Modal";
import { PayoutCard } from "./PayoutCard";
import { ClaimModal, type ClaimModalPhase } from "./ClaimModal";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ClaimStepIndicator } from "./ClaimStepIndicator";
const QUOTE_TO_MODAL_DELAY_MS = 900;
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
function isValidLightningAddress(addr: string): boolean {
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
}
interface Props {
pubkey: string | null;
onPubkeyChange: (pk: string | null) => void;
onClaimSuccess?: () => void;
}
export function ClaimFlow({ pubkey, onPubkeyChange, onClaimSuccess }: Props) {
const [config, setConfig] = React.useState<FaucetConfig | null>(null);
const [profile, setProfile] = React.useState<UserProfile | null>(null);
const [lightningAddress, setLightningAddress] = React.useState("");
const [lightningAddressTouched, setLightningAddressTouched] = React.useState(false);
const [showQuoteModal, setShowQuoteModal] = useState(false);
const quoteModalDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const claim = useClaimFlow();
React.useEffect(() => {
getConfig().then(setConfig).catch(() => setConfig(null));
}, []);
React.useEffect(() => {
if (!pubkey) {
setProfile(null);
setLightningAddress("");
return;
}
postUserRefreshProfile()
.then((p) => {
setProfile(p);
const addr = (p.lightning_address ?? "").trim();
setLightningAddress(addr);
})
.catch(() => setProfile(null));
}, [pubkey]);
useEffect(() => {
if (claim.quote && claim.claimState === "quote_ready") {
quoteModalDelayRef.current = setTimeout(() => {
setShowQuoteModal(true);
}, QUOTE_TO_MODAL_DELAY_MS);
return () => {
if (quoteModalDelayRef.current) clearTimeout(quoteModalDelayRef.current);
};
}
setShowQuoteModal(false);
}, [claim.quote, claim.claimState]);
const handleDisconnect = () => {
onPubkeyChange(null);
setProfile(null);
claim.cancelQuote();
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
};
const handleDone = () => {
claim.resetSuccess();
onClaimSuccess?.();
};
const handleCheckEligibility = () => {
claim.checkEligibility(lightningAddress);
};
const quoteExpired =
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
const modalOpen =
(claim.quote != null && (showQuoteModal || claim.loading === "confirm" || claim.confirmError != null)) ||
claim.success != null;
const modalPhase: ClaimModalPhase = claim.success
? "success"
: claim.loading === "confirm"
? "sending"
: claim.confirmError != null
? "failure"
: "quote";
const modalTitle =
modalPhase === "quote" || modalPhase === "sending"
? "Confirm payout"
: modalPhase === "success"
? "Sats sent"
: "Claim";
return (
<div className="content claim-flow-content">
<div className="claim-flow-layout">
<ClaimStepIndicator claimState={claim.claimState} hasPubkey={!!pubkey} />
<div className="claim-flow-main">
<h2>Get sats from the faucet</h2>
<p className="claim-flow-desc">
Connect with Nostr once to sign in. Your Lightning address is filled from your profile. Check eligibility, then confirm in the modal to receive sats.
</p>
<ConnectNostr
pubkey={pubkey}
displayName={profile?.name}
onConnect={(pk) => onPubkeyChange(pk)}
onDisconnect={handleDisconnect}
/>
{pubkey && (
<>
<PayoutCard
config={{
minSats: Number(config?.faucetMinSats) || 1,
maxSats: Number(config?.faucetMaxSats) || 5,
}}
quote={claim.quote}
expired={quoteExpired}
onRecheck={() => {
claim.cancelQuote();
claim.clearDenial();
}}
/>
<div className="claim-flow-address-section">
<div className="address-row">
<label>Your Lightning Address:</label>
<input
type="text"
value={lightningAddress}
onChange={(e) => setLightningAddress(e.target.value)}
onBlur={() => setLightningAddressTouched(true)}
placeholder="you@wallet.com"
disabled={!!claim.quote}
readOnly={!!profile?.lightning_address && lightningAddress.trim() === (profile.lightning_address ?? "").trim()}
title={profile?.lightning_address ? "From your Nostr profile" : undefined}
aria-invalid={lightningAddressInvalid || undefined}
aria-describedby={lightningAddressInvalid ? "lightning-address-hint" : undefined}
/>
<button
type="button"
className="btn-primary btn-eligibility"
onClick={handleCheckEligibility}
disabled={claim.loading !== "idle"}
>
{claim.loading === "quote"
? ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]
: "Check eligibility"}
</button>
</div>
{lightningAddressInvalid && (
<p id="lightning-address-hint" className="claim-flow-input-hint" role="alert">
Enter a valid Lightning address (user@domain)
</p>
)}
{profile?.lightning_address && lightningAddress.trim() === profile.lightning_address.trim() && (
<div
className="profile-hint profile-hint-pill"
title="From your Nostr profile (kind:0 lightning field)"
>
<span className="profile-hint-pill-icon" aria-hidden></span>
Filled from profile
</div>
)}
</div>
{claim.denial && (
<ClaimDenialPanel denial={claim.denial} onDismiss={claim.clearDenial} />
)}
</>
)}
</div>
</div>
{modalOpen && (
<Modal
open={true}
onClose={() => {
if (claim.success) {
handleDone();
} else {
claim.cancelQuote();
claim.clearConfirmError();
setShowQuoteModal(false);
}
}}
title={modalTitle}
preventClose={claim.loading === "confirm"}
>
<ClaimModal
phase={modalPhase}
quote={claim.quote}
confirmResult={claim.success}
confirmError={claim.confirmError}
lightningAddress={lightningAddress}
quoteExpired={quoteExpired}
onConfirm={claim.confirmClaim}
onCancel={() => {
claim.cancelQuote();
claim.clearConfirmError();
setShowQuoteModal(false);
}}
onRetry={claim.confirmClaim}
onDone={handleDone}
/>
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Countdown } from "./Countdown";
import { useToast } from "../contexts/ToastContext";
import type { QuoteResult, ConfirmResult } from "../api";
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
export type ClaimModalPhase = "quote" | "sending" | "success" | "failure";
interface ClaimModalProps {
phase: ClaimModalPhase;
quote: QuoteResult | null;
confirmResult: ConfirmResult | null;
confirmError: ConfirmErrorState | null;
lightningAddress: string;
quoteExpired: boolean;
onConfirm: () => void;
onCancel: () => void;
onRetry: () => void;
onDone: () => void;
}
function CheckIcon() {
return (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
function SpinnerIcon() {
return (
<svg className="claim-modal-spinner" width="40" height="40" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function ClaimModal({
phase,
quote,
confirmResult,
confirmError,
lightningAddress,
quoteExpired,
onConfirm,
onCancel,
onRetry,
onDone,
}: ClaimModalProps) {
const { showToast } = useToast();
const [paymentHashExpanded, setPaymentHashExpanded] = useState(false);
const handleShare = () => {
const amount = confirmResult?.payout_sats ?? 0;
const text = `Just claimed ${amount} sats from the faucet!`;
navigator.clipboard.writeText(text).then(() => showToast("Copied"));
};
const copyPaymentHash = () => {
const hash = confirmResult?.payment_hash;
if (!hash) return;
navigator.clipboard.writeText(hash).then(() => showToast("Copied"));
};
return (
<div className="claim-modal-content">
<AnimatePresence mode="wait">
{phase === "quote" && quote && (
<motion.div
key="quote"
className="claim-modal-phase claim-modal-quote"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="claim-modal-phase-title">Confirm payout</h3>
<div className="claim-modal-quote-amount-large">
<span className="claim-modal-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-modal-quote-amount-unit">sats</span>
</div>
<div className="claim-modal-quote-destination">
<span className="claim-modal-quote-destination-label">To</span>
<span className="claim-modal-quote-destination-value">{lightningAddress}</span>
</div>
<div className="claim-modal-quote-expiry-ring">
<Countdown targetUnixSeconds={quote.expires_at} format="clock" />
<span className="claim-modal-quote-expiry-label">Expires in</span>
</div>
<div className="claim-modal-actions">
<button type="button" className="btn-primary btn-primary-large" onClick={onConfirm} disabled={quoteExpired}>
Send sats
</button>
<button type="button" className="btn-secondary" onClick={onCancel}>
Cancel
</button>
</div>
</motion.div>
)}
{phase === "sending" && (
<motion.div
key="sending"
className="claim-modal-phase claim-modal-sending"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<SpinnerIcon />
<p className="claim-modal-sending-text">Sending sats via Lightning</p>
</motion.div>
)}
{phase === "success" && confirmResult && (
<motion.div
key="success"
className="claim-modal-phase claim-modal-success"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="claim-modal-success-icon">
<CheckIcon />
</div>
<p className="claim-modal-success-headline">Sent {confirmResult.payout_sats ?? 0} sats</p>
{confirmResult.payment_hash && (
<div className="claim-modal-success-payment-hash">
<button
type="button"
className="claim-modal-payment-hash-btn"
onClick={() => {
setPaymentHashExpanded((e) => !e);
}}
aria-expanded={paymentHashExpanded}
>
{paymentHashExpanded
? confirmResult.payment_hash
: `${confirmResult.payment_hash.slice(0, 12)}`}
</button>
<button type="button" className="btn-secondary claim-modal-copy-btn" onClick={copyPaymentHash}>
Copy
</button>
</div>
)}
{confirmResult.next_eligible_at != null && (
<p className="claim-modal-success-next">
Next eligible: <Countdown targetUnixSeconds={confirmResult.next_eligible_at} format="duration" />
</p>
)}
<div className="claim-modal-actions">
<button type="button" className="btn-primary btn-primary-large" onClick={onDone}>
Done
</button>
<button type="button" className="btn-secondary" onClick={handleShare}>
Share
</button>
</div>
</motion.div>
)}
{phase === "failure" && confirmError && (
<motion.div
key="failure"
className="claim-modal-phase claim-modal-failure"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<p className="claim-modal-failure-message">{confirmError.message}</p>
<div className="claim-modal-actions">
{confirmError.allowRetry && !quoteExpired && (
<button type="button" className="btn-primary" onClick={onRetry}>
Try again
</button>
)}
{(!confirmError.allowRetry || quoteExpired) && (
<button type="button" className="btn-primary" onClick={onCancel}>
Re-check eligibility
</button>
)}
<button type="button" className="btn-secondary" onClick={onCancel}>
Close
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useState, useEffect } from "react";
import type { QuoteResult } from "../api";
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
interface ClaimQuoteModalProps {
quote: QuoteResult;
lightningAddress: string;
loading: boolean;
confirmError: ConfirmErrorState | null;
onConfirm: () => void;
onCancel: () => void;
onRetry: () => void;
}
function formatCountdown(expiresAt: number): string {
const now = Math.floor(Date.now() / 1000);
const left = Math.max(0, expiresAt - now);
const m = Math.floor(left / 60);
const s = left % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function ClaimQuoteModal({
quote,
lightningAddress,
loading,
confirmError,
onConfirm,
onCancel,
onRetry,
}: ClaimQuoteModalProps) {
const [countdown, setCountdown] = useState(() => formatCountdown(quote.expires_at));
const expired = quote.expires_at <= Math.floor(Date.now() / 1000);
useEffect(() => {
const t = setInterval(() => {
setCountdown(formatCountdown(quote.expires_at));
}, 1000);
return () => clearInterval(t);
}, [quote.expires_at]);
if (confirmError) {
return (
<div className="claim-modal-content claim-quote-error">
<p className="claim-modal-error-text">{confirmError.message}</p>
<div className="claim-modal-actions">
{confirmError.allowRetry && (
<button type="button" className="btn-primary" onClick={onRetry} disabled={loading}>
{loading ? "Retrying…" : "Retry"}
</button>
)}
<button type="button" className="btn-secondary" onClick={onCancel}>
Close
</button>
</div>
</div>
);
}
return (
<div className="claim-modal-content">
<div className="claim-quote-amount">
<span className="claim-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-quote-amount-unit">sats</span>
</div>
<div className="claim-quote-countdown">
{expired ? (
<span className="claim-quote-expired">Quote expired</span>
) : (
<>Expires in {countdown}</>
)}
</div>
<div className="claim-quote-address">
<span className="claim-quote-address-label">To</span>
<span className="claim-quote-address-value">{lightningAddress}</span>
</div>
<div className="claim-modal-actions">
<button
type="button"
className="btn-primary"
onClick={onConfirm}
disabled={loading || expired}
>
{loading ? "Sending…" : "Confirm"}
</button>
<button type="button" className="btn-secondary" onClick={onCancel} disabled={loading}>
Cancel
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import type { ClaimFlowState } from "../hooks/useClaimFlow";
const STEPS = [
{ id: 1, label: "Connect" },
{ id: 2, label: "Check" },
{ id: 3, label: "Confirm" },
{ id: 4, label: "Receive" },
] as const;
function stepFromState(claimState: ClaimFlowState, hasPubkey: boolean): number {
if (!hasPubkey) return 1;
switch (claimState) {
case "connected_idle":
case "quoting":
case "denied":
return 2;
case "quote_ready":
case "confirming":
case "error":
return 3;
case "success":
return 4;
default:
return 2;
}
}
interface ClaimStepIndicatorProps {
claimState: ClaimFlowState;
hasPubkey: boolean;
}
export function ClaimStepIndicator({ claimState, hasPubkey }: ClaimStepIndicatorProps) {
const currentStep = stepFromState(claimState, hasPubkey);
return (
<div className="claim-step-indicator" role="list" aria-label="Claim steps">
{STEPS.map((step, index) => {
const isActive = step.id === currentStep;
const isPast = step.id < currentStep;
return (
<div
key={step.id}
className={`claim-step-indicator-item ${isActive ? "claim-step-indicator-item--active" : ""} ${isPast ? "claim-step-indicator-item--past" : ""}`}
role="listitem"
>
<div className="claim-step-indicator-dot" aria-current={isActive ? "step" : undefined} />
<span className="claim-step-indicator-label">{step.label}</span>
{index < STEPS.length - 1 && <div className="claim-step-indicator-line" />}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import confetti from "canvas-confetti";
import type { ConfirmResult } from "../api";
interface ClaimSuccessModalProps {
result: ConfirmResult;
onClose: () => void;
}
export function ClaimSuccessModal({ result, onClose }: ClaimSuccessModalProps) {
const amount = result.payout_sats ?? 0;
const nextAt = result.next_eligible_at;
useEffect(() => {
const t = setTimeout(() => {
confetti({
particleCount: 60,
spread: 60,
origin: { y: 0.6 },
colors: ["#f97316", "#22c55e", "#eab308"],
});
}, 300);
return () => clearTimeout(t);
}, []);
return (
<div className="claim-modal-content claim-success-content">
<div className="claim-success-amount">
<span className="claim-success-amount-value">{amount}</span>
<span className="claim-success-amount-unit">sats sent</span>
</div>
{nextAt != null && (
<p className="claim-success-next">
Next claim after <strong>{new Date(nextAt * 1000).toLocaleString()}</strong>
</p>
)}
<button type="button" className="btn-primary claim-success-close" onClick={onClose}>
Done
</button>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { postUserRefreshProfile, type UserProfile } from "../api";
import { useClaimFlow } from "../hooks/useClaimFlow";
import { StepIndicator } from "./StepIndicator";
import { ConnectStep } from "./ConnectStep";
import { EligibilityStep } from "./EligibilityStep";
import { ConfirmStep } from "./ConfirmStep";
import { SuccessStep } from "./SuccessStep";
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
function isValidLightningAddress(addr: string): boolean {
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
}
function getWizardStep(
hasPubkey: boolean,
claimState: ReturnType<typeof useClaimFlow>["claimState"]
): 1 | 2 | 3 | 4 {
if (!hasPubkey) return 1;
if (claimState === "success") return 4;
if (claimState === "quote_ready" || claimState === "confirming" || claimState === "error") return 3;
return 2;
}
interface ClaimWizardProps {
pubkey: string | null;
onPubkeyChange: (pk: string | null) => void;
onClaimSuccess?: () => void;
}
export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [lightningAddress, setLightningAddress] = useState("");
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
const wizardRef = useRef<HTMLDivElement>(null);
const claim = useClaimFlow();
const currentStep = useMemo(
() => getWizardStep(!!pubkey, claim.claimState),
[pubkey, claim.claimState]
);
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLightningAddress("");
return;
}
postUserRefreshProfile()
.then((p) => {
setProfile(p);
const addr = (p.lightning_address ?? "").trim();
setLightningAddress(addr);
})
.catch(() => setProfile(null));
}, [pubkey]);
useEffect(() => {
if (currentStep === 2 && pubkey && wizardRef.current) {
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [currentStep, pubkey]);
useEffect(() => {
if (currentStep === 4 && wizardRef.current) {
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [currentStep]);
const handleDisconnect = () => {
onPubkeyChange(null);
setProfile(null);
claim.cancelQuote();
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
};
const handleDone = () => {
claim.resetSuccess();
onClaimSuccess?.();
};
const handleClaimAgain = () => {
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
setLightningAddressTouched(false);
// Stay on step 2 (eligibility)
};
const handleCheckEligibility = () => {
claim.checkEligibility(lightningAddress);
};
const handleCancelQuote = () => {
claim.cancelQuote();
claim.clearConfirmError();
};
const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const fromProfile =
Boolean(profile?.lightning_address) &&
lightningAddress.trim() === (profile?.lightning_address ?? "").trim();
const quoteExpired =
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
return (
<div className="content claim-wizard-content" ref={wizardRef}>
<div className="ClaimWizard claim-wizard-root">
<header className="claim-wizard-header">
<div className="claim-wizard-header-row">
<h2 className="claim-wizard-title">Get sats from the faucet</h2>
{pubkey && (
<button
type="button"
className="claim-wizard-disconnect"
onClick={handleDisconnect}
aria-label="Disconnect account"
>
Disconnect
</button>
)}
</div>
<StepIndicator currentStep={currentStep} />
</header>
<div className="claim-wizard-body">
{currentStep === 1 && (
<ConnectStep
pubkey={pubkey}
displayName={profile?.name}
onConnect={(pk) => onPubkeyChange(pk)}
onDisconnect={handleDisconnect}
/>
)}
{currentStep === 2 && (
<EligibilityStep
lightningAddress={lightningAddress}
onLightningAddressChange={setLightningAddress}
lightningAddressTouched={lightningAddressTouched}
setLightningAddressTouched={setLightningAddressTouched}
invalid={lightningAddressInvalid}
fromProfile={fromProfile}
loading={claim.loading === "quote"}
eligibilityProgressStep={claim.eligibilityProgressStep}
denial={claim.denial}
onCheckEligibility={handleCheckEligibility}
onClearDenial={claim.clearDenial}
onCheckAgain={() => {
claim.clearDenial();
}}
/>
)}
{currentStep === 3 && claim.quote && (
<ConfirmStep
quote={claim.quote}
lightningAddress={lightningAddress}
quoteExpired={quoteExpired}
confirming={claim.loading === "confirm"}
confirmError={claim.confirmError}
onConfirm={claim.confirmClaim}
onCancel={handleCancelQuote}
onRetry={claim.confirmClaim}
/>
)}
{currentStep === 4 && claim.success && (
<SuccessStep
result={claim.success}
onDone={handleDone}
onClaimAgain={handleClaimAgain}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Countdown } from "./Countdown";
import type { QuoteResult } from "../api";
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
interface ConfirmStepProps {
quote: QuoteResult | null;
lightningAddress: string;
quoteExpired: boolean;
confirming: boolean;
confirmError: ConfirmErrorState | null;
onConfirm: () => void;
onCancel: () => void;
onRetry: () => void;
}
function SpinnerIcon() {
return (
<svg className="claim-wizard-spinner" width="32" height="32" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function ConfirmStep({
quote,
lightningAddress,
quoteExpired,
confirming,
confirmError,
onConfirm,
onCancel,
onRetry,
}: ConfirmStepProps) {
if (!quote) return null;
return (
<div className="claim-wizard-step claim-wizard-step-confirm">
<AnimatePresence mode="wait">
{confirming ? (
<motion.div
key="sending"
className="claim-wizard-confirm-sending"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<SpinnerIcon />
<p className="claim-wizard-confirm-sending-text">Sending sats via Lightning</p>
</motion.div>
) : confirmError ? (
<motion.div
key="error"
className="claim-wizard-confirm-error"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="claim-wizard-step-title">Something went wrong</h3>
<p className="claim-wizard-confirm-error-message">{confirmError.message}</p>
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
{confirmError.allowRetry && !quoteExpired && (
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onRetry}>
Try again
</button>
)}
<button type="button" className="btn-secondary" onClick={onCancel}>
{confirmError.allowRetry && !quoteExpired ? "Cancel" : "Back"}
</button>
</div>
</motion.div>
) : (
<motion.div
key="quote"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="claim-wizard-step-title">Confirm payout</h3>
<div className="claim-wizard-quote-amount">
<span className="claim-wizard-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-wizard-quote-amount-unit">sats</span>
</div>
<div className="claim-wizard-quote-expiry">
<Countdown targetUnixSeconds={quote.expires_at} format="clock" />
<span className="claim-wizard-quote-expiry-label">Locked for</span>
</div>
<p className="claim-wizard-quote-destination">
To <strong>{lightningAddress}</strong>
</p>
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
<button
type="button"
className="btn-primary claim-wizard-btn-primary"
onClick={onConfirm}
disabled={quoteExpired}
>
Confirm payout
</button>
<button type="button" className="btn-secondary" onClick={onCancel}>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React from "react";
import { clearToken } from "../api";
import { nip19 } from "nostr-tools";
import { LoginModal } from "./LoginModal";
interface Props {
pubkey: string | null;
displayName?: string | null;
onConnect: (pubkey: string) => void;
onDisconnect: () => void;
}
function getInitial(name: string | null | undefined): string {
const n = (name ?? "").trim();
if (n.length > 0) return n[0].toUpperCase();
return "?";
}
export function ConnectNostr({ pubkey, displayName, onConnect, onDisconnect }: Props) {
const [modalOpen, setModalOpen] = React.useState(false);
const handleDisconnect = () => {
clearToken();
onDisconnect();
};
if (pubkey) {
const npub = nip19.npubEncode(pubkey);
const shortNpub = npub.slice(0, 12) + "…";
const display = displayName?.trim() || shortNpub;
const initial = getInitial(displayName);
return (
<div className="connect-pill-wrap">
<div className="connect-pill">
<span className="connect-pill-dot" aria-hidden />
<span className="connect-pill-avatar" aria-hidden>
{initial}
</span>
<span className="connect-pill-name">{display}</span>
<span className="connect-pill-npub">{shortNpub}</span>
</div>
<button
type="button"
className="connect-pill-disconnect"
onClick={handleDisconnect}
aria-label="Disconnect"
title="Disconnect"
>
<span className="connect-pill-disconnect-icon" aria-hidden></span>
</button>
</div>
);
}
return (
<>
<div className="address-row">
<button type="button" onClick={() => setModalOpen(true)}>
Connect Nostr
</button>
</div>
<LoginModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={onConnect}
/>
</>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { ConnectNostr } from "./ConnectNostr";
interface ConnectStepProps {
pubkey: string | null;
displayName?: string | null;
onConnect: (pubkey: string) => void;
onDisconnect: () => void;
}
export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: ConnectStepProps) {
return (
<div className="claim-wizard-step claim-wizard-step-connect">
<h3 className="claim-wizard-step-title">Connect your Nostr account</h3>
<p className="claim-wizard-step-desc">
Sign in with your Nostr key to prove your identity. Your Lightning address can be filled from your profile.
</p>
<div className="claim-wizard-connect-cta">
<ConnectNostr
pubkey={pubkey}
displayName={displayName}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useState, useEffect, useRef } from "react";
interface CountUpNumberProps {
value: number;
durationMs?: number;
className?: string;
}
export function CountUpNumber({ value, durationMs = 600, className }: CountUpNumberProps) {
const [display, setDisplay] = useState(0);
const prevValue = useRef(value);
const startTime = useRef<number | null>(null);
const rafId = useRef<number>(0);
useEffect(() => {
if (value === prevValue.current) return;
prevValue.current = value;
setDisplay(0);
startTime.current = null;
const tick = (now: number) => {
if (startTime.current === null) startTime.current = now;
const elapsed = now - startTime.current;
const t = Math.min(elapsed / durationMs, 1);
const eased = 1 - (1 - t) * (1 - t);
setDisplay(Math.round(eased * value));
if (t < 1) rafId.current = requestAnimationFrame(tick);
};
rafId.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId.current);
}, [value, durationMs]);
return <span className={className}>{display}</span>;
}

View File

@@ -0,0 +1,50 @@
import { useState, useEffect } from "react";
export type CountdownFormat = "clock" | "duration";
interface CountdownProps {
targetUnixSeconds: number;
format?: CountdownFormat;
className?: string;
}
function formatClock(secondsLeft: number): string {
const m = Math.floor(secondsLeft / 60);
const s = secondsLeft % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/**
* Format seconds into "Xd Xh Xm" for "next eligible" style countdown.
* Exported for reuse in denial panel and success modal.
*/
export function formatDuration(secondsLeft: number): string {
if (secondsLeft <= 0) return "0d 0h 0m";
const d = Math.floor(secondsLeft / 86400);
const h = Math.floor((secondsLeft % 86400) / 3600);
const m = Math.floor((secondsLeft % 3600) / 60);
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
parts.push(`${h}h`);
parts.push(`${m}m`);
return parts.join(" ");
}
function getSecondsLeft(targetUnixSeconds: number): number {
return Math.max(0, targetUnixSeconds - Math.floor(Date.now() / 1000));
}
export function Countdown({ targetUnixSeconds, format = "clock", className }: CountdownProps) {
const [secondsLeft, setSecondsLeft] = useState(() => getSecondsLeft(targetUnixSeconds));
useEffect(() => {
setSecondsLeft(getSecondsLeft(targetUnixSeconds));
const t = setInterval(() => {
setSecondsLeft(getSecondsLeft(targetUnixSeconds));
}, 1000);
return () => clearInterval(t);
}, [targetUnixSeconds]);
const text = format === "duration" ? formatDuration(secondsLeft) : formatClock(secondsLeft);
return <span className={className}>{text}</span>;
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { getDeposit, postRedeemCashu, type DepositInfo } from "../api";
import { useToast } from "../contexts/ToastContext";
export function DepositSection() {
const [deposit, setDeposit] = useState<DepositInfo | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [cashuToken, setCashuToken] = useState("");
const [cashuLoading, setCashuLoading] = useState(false);
const { showToast } = useToast();
useEffect(() => {
getDeposit().then((d) => {
setDeposit(d);
const qrContent = d.lightningAddress || d.lnurlp;
if (qrContent) QRCode.toDataURL(qrContent, { width: 180 }).then(setQrDataUrl);
}).catch(() => setDeposit(null));
}, []);
const copyAddress = () => {
if (!deposit?.lightningAddress) return;
navigator.clipboard.writeText(deposit.lightningAddress);
showToast("Copied");
};
const handleRedeemCashu = async () => {
const token = cashuToken.trim();
if (!token || !token.toLowerCase().startsWith("cashu")) {
showToast("Enter a valid Cashu token (cashuA... or cashuB...)");
return;
}
setCashuLoading(true);
try {
const result = await postRedeemCashu(token);
setCashuToken("");
const amount = result.amount ?? result.netAmount ?? result.invoiceAmount;
showToast(amount != null ? `Redeemed ${amount} sats to faucet!` : "Cashu token redeemed to faucet.");
} catch (e) {
showToast(e instanceof Error ? e.message : "Redeem failed");
} finally {
setCashuLoading(false);
}
};
if (!deposit) return null;
if (!deposit.lightningAddress && !deposit.lnurlp) return null;
return (
<div className="deposit-box">
<h3>Fund the faucet</h3>
{deposit.lightningAddress && (
<div className="copy-row">
<input type="text" readOnly value={deposit.lightningAddress} />
<button type="button" onClick={copyAddress}>
Copy
</button>
</div>
)}
{qrDataUrl && (
<div className="qr-wrap">
<img src={qrDataUrl} alt="Deposit QR" width={180} height={180} />
</div>
)}
<div className="cashu-redeem">
<label className="cashu-redeem-label">Redeem Cashu token to faucet</label>
<textarea
className="cashu-redeem-input"
placeholder="Paste Cashu token (cashuA... or cashuB...)"
value={cashuToken}
onChange={(e) => setCashuToken(e.target.value)}
rows={2}
disabled={cashuLoading}
/>
<button
type="button"
className="cashu-redeem-btn"
onClick={handleRedeemCashu}
disabled={cashuLoading || !cashuToken.trim()}
>
{cashuLoading ? "Redeeming…" : "Redeem to faucet"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React from "react";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import type { DenialState } from "../hooks/useClaimFlow";
interface EligibilityStepProps {
lightningAddress: string;
onLightningAddressChange: (value: string) => void;
lightningAddressTouched: boolean;
setLightningAddressTouched: (t: boolean) => void;
invalid: boolean;
fromProfile: boolean;
loading: boolean;
eligibilityProgressStep: number | null;
denial: DenialState | null;
onCheckEligibility: () => void;
onClearDenial: () => void;
onCheckAgain?: () => void;
}
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
export function EligibilityStep({
lightningAddress,
onLightningAddressChange,
lightningAddressTouched,
setLightningAddressTouched,
invalid,
fromProfile,
loading,
eligibilityProgressStep,
denial,
onCheckEligibility,
onClearDenial,
onCheckAgain,
}: EligibilityStepProps) {
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
return (
<div className="claim-wizard-step claim-wizard-step-eligibility">
<h3 className="claim-wizard-step-title">Check eligibility</h3>
<p className="claim-wizard-step-desc">
Enter your Lightning address. Well verify cooldown and calculate your payout.
</p>
<div className="claim-wizard-address-row">
<label htmlFor="wizard-lightning-address">Lightning address</label>
<input
id="wizard-lightning-address"
type="text"
value={lightningAddress}
onChange={(e) => onLightningAddressChange(e.target.value)}
onBlur={() => setLightningAddressTouched(true)}
placeholder="you@wallet.com"
disabled={loading}
readOnly={fromProfile}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
/>
{fromProfile && (
<span className="claim-wizard-profile-badge" title="From your Nostr profile">
From profile
</span>
)}
</div>
{invalid && (
<p id="wizard-lightning-hint" className="claim-wizard-input-hint" role="alert">
Enter a valid Lightning address (user@domain)
</p>
)}
{loading ? (
<div className="claim-wizard-progress" role="status" aria-live="polite">
<div className="claim-wizard-progress-bar" />
<p className="claim-wizard-progress-text">
{ELIGIBILITY_PROGRESS_STEPS[eligibilityProgressStep ?? 0]}
</p>
</div>
) : (
<div className="claim-wizard-step-actions">
<button
type="button"
className="btn-primary claim-wizard-btn-primary"
onClick={onCheckEligibility}
disabled={!canCheck}
>
Check eligibility
</button>
</div>
)}
{denial && (
<div className="claim-wizard-denial-wrap">
<ClaimDenialPanel
denial={denial}
onDismiss={onClearDenial}
onCheckAgain={onCheckAgain}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="site-footer">
<div className="site-footer-inner">
<nav className="site-footer-nav">
<Link to="/">Home</Link>
<Link to="/transactions">Transactions</Link>
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">
Bitcoin.org
</a>
</nav>
<p className="site-footer-copy">
© {year} Sats Faucet. Fund the faucet to keep it running.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,29 @@
import { Link, useLocation } from "react-router-dom";
export function Header() {
const location = useLocation();
return (
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-logo">
<span className="site-logo-text">Sats Faucet</span>
</Link>
<nav className="site-nav">
<Link
to="/"
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}
>
Home
</Link>
<Link
to="/transactions"
className={location.pathname === "/transactions" ? "site-nav-link active" : "site-nav-link"}
>
Transactions
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,323 @@
import { useCallback, useRef, useState } from "react";
import { nip19, SimplePool, generateSecretKey, finalizeEvent } from "nostr-tools";
import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
import {
postAuthLoginWithSigner,
postAuthLogin,
postUserRefreshProfile,
setToken,
hasNostr,
type ApiError,
} from "../api";
import { Modal } from "./Modal";
import { RemoteSignerQR } from "./RemoteSignerQR";
import { isValidRemoteSignerInput } from "../utils/remoteSignerValidation";
const REMOTE_SIGNER_MODE_KEY = "nostr_remote_signer_mode";
const MOBILE_BREAKPOINT = 640;
type Tab = "extension" | "remote" | "nsec";
type RemoteSignerMode = "paste" | "scan";
function getDefaultRemoteSignerMode(): RemoteSignerMode {
if (typeof window === "undefined") return "paste";
const saved = localStorage.getItem(REMOTE_SIGNER_MODE_KEY);
if (saved === "paste" || saved === "scan") return saved;
return window.innerWidth <= MOBILE_BREAKPOINT ? "scan" : "paste";
}
interface Props {
open: boolean;
onClose: () => void;
onSuccess: (pubkey: string) => void;
}
export function LoginModal({ open, onClose, onSuccess }: Props) {
const [tab, setTab] = useState<Tab>("extension");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [remoteSignerMode, setRemoteSignerModeState] = useState<RemoteSignerMode>(getDefaultRemoteSignerMode);
const setRemoteSignerMode = useCallback((mode: RemoteSignerMode) => {
setRemoteSignerModeState(mode);
localStorage.setItem(REMOTE_SIGNER_MODE_KEY, mode);
}, []);
const [bunkerInput, setBunkerInput] = useState("");
const [pasteInlineError, setPasteInlineError] = useState<string | null>(null);
const [nsecNpubInput, setNsecNpubInput] = useState("");
const handleClose = () => {
if (!loading) {
setError(null);
setPasteInlineError(null);
setBunkerInput("");
setNsecNpubInput("");
onClose();
}
};
const runLogin = useCallback(
async (sign: () => Promise<{ token: string; pubkey: string }>) => {
setLoading(true);
setError(null);
setPasteInlineError(null);
try {
const { token, pubkey } = await sign();
setToken(token);
// Trigger profile fetch so backend has token before ClaimFlow effect runs (helps remote signer login)
postUserRefreshProfile().catch(() => {});
onSuccess(pubkey);
handleClose();
} catch (e) {
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
setError(msg);
} finally {
setLoading(false);
}
},
[onSuccess]
);
const runLoginRef = useRef(runLogin);
runLoginRef.current = runLogin;
const handleExtension = () => {
if (!hasNostr()) {
setError("Install a Nostr extension (e.g. nos2x, Alby) to use this option.");
return;
}
runLogin(() => postAuthLogin());
};
const handleRemoteSignerPaste = async () => {
const raw = bunkerInput.trim();
if (!raw) {
setError("Enter a bunker URL (bunker://...) or NIP-05 (user@domain).");
return;
}
if (!isValidRemoteSignerInput(raw)) {
setPasteInlineError("Enter a valid bunker URL (bunker://...) or NIP-05 (name@domain.com).");
return;
}
setPasteInlineError(null);
const bp = await parseBunkerInput(raw);
if (!bp || bp.relays.length === 0) {
setError("Could not parse bunker URL or NIP-05. Need at least one relay.");
return;
}
const pool = new SimplePool();
const clientSecret = generateSecretKey();
try {
const signer = BunkerSigner.fromBunker(clientSecret, bp, { pool });
await signer.connect();
const sign = (e: { kind: number; tags: string[][]; content: string; created_at: number }) => signer.signEvent(e);
await runLogin(() => postAuthLoginWithSigner(sign));
} finally {
pool.destroy();
}
};
const handleRemoteSignerConnectViaQR = useCallback(
async (sign: Parameters<typeof postAuthLoginWithSigner>[0]) => {
await runLoginRef.current(() => postAuthLoginWithSigner(sign));
},
[]
);
const handleRemoteSignerQRError = useCallback((message: string | null) => {
setError(message);
}, []);
const handlePasteBlur = () => {
const raw = bunkerInput.trim();
if (!raw) {
setPasteInlineError(null);
return;
}
setPasteInlineError(isValidRemoteSignerInput(raw) ? null : "Enter a valid bunker URL (bunker://...) or NIP-05 (name@domain.com).");
};
const handleNsecNpub = async () => {
const raw = nsecNpubInput.trim();
if (!raw) {
setError("Paste your nsec (secret key) or npub (public key).");
return;
}
try {
const decoded = nip19.decode(raw);
if (decoded.type === "npub") {
setError("Npub (public key) cannot sign. Paste your nsec to log in and claim, or use Extension / Remote signer.");
return;
}
if (decoded.type !== "nsec") {
setError("Unsupported format. Use nsec or npub.");
return;
}
const secretKey = decoded.data;
const sign = (e: { kind: number; tags: string[][]; content: string; created_at: number }) =>
Promise.resolve(finalizeEvent(e, secretKey));
await runLogin(() => postAuthLoginWithSigner(sign));
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid nsec/npub. Check the format.");
}
};
return (
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading}>
<div className="login-modal-tabs">
{(["extension", "remote", "nsec"] as const).map((t) => (
<button
key={t}
type="button"
className={`login-modal-tab ${tab === t ? "active" : ""}`}
onClick={() => {
setTab(t);
setError(null);
setPasteInlineError(null);
}}
>
{t === "extension" && "Extension"}
{t === "remote" && "Remote signer"}
{t === "nsec" && "Nsec / Npub"}
</button>
))}
</div>
<div className="login-modal-body">
{tab === "extension" && (
<div className="login-method">
<p className="login-method-desc">Use a Nostr browser extension (NIP-07) such as nos2x or Alby.</p>
<button
type="button"
className="login-method-btn"
onClick={handleExtension}
disabled={loading || !hasNostr()}
>
{loading ? "Connecting…" : hasNostr() ? "Login with extension" : "Extension not detected"}
</button>
</div>
)}
{tab === "remote" && (
<>
<div className="login-segment" role="group" aria-label="Remote signer method">
<button
type="button"
className="login-segment-option"
aria-pressed={remoteSignerMode === "paste"}
onClick={() => {
setRemoteSignerMode("paste");
setError(null);
}}
>
Paste link
</button>
<button
type="button"
className="login-segment-option"
aria-pressed={remoteSignerMode === "scan"}
onClick={() => {
setRemoteSignerMode("scan");
setError(null);
}}
>
Scan QR
</button>
</div>
{remoteSignerMode === "paste" && (
<div className="login-method">
<h3 className="login-method-title">Connect a remote signer</h3>
<p className="login-method-desc">
Paste a bunker URL or NIP-05. Your signer stays in control.
</p>
<input
type="text"
className="login-modal-input"
placeholder="bunker://... or name@domain.com"
value={bunkerInput}
onChange={(e) => {
setBunkerInput(e.target.value);
if (pasteInlineError) setPasteInlineError(null);
}}
onBlur={handlePasteBlur}
disabled={loading}
aria-invalid={!!pasteInlineError}
aria-describedby={pasteInlineError ? "paste-inline-error" : undefined}
/>
{pasteInlineError && (
<p id="paste-inline-error" className="login-modal-inline-error" role="alert">
{pasteInlineError}
</p>
)}
<button
type="button"
className="login-method-btn"
onClick={handleRemoteSignerPaste}
disabled={loading}
>
{loading ? "Connecting…" : "Connect"}
</button>
<button
type="button"
className="login-modal-secondary-link"
onClick={() => setRemoteSignerMode("scan")}
>
Prefer scanning? Scan QR
</button>
</div>
)}
{remoteSignerMode === "scan" && (
<div className="login-method">
<h3 className="login-method-title">Scan with your signer</h3>
<p className="login-method-desc">
Open your signer app and scan to connect. Approve requests in the signer.
</p>
<RemoteSignerQR
onConnect={handleRemoteSignerConnectViaQR}
onError={handleRemoteSignerQRError}
disabled={false}
signingIn={loading}
/>
<button
type="button"
className="login-modal-secondary-link"
onClick={() => setRemoteSignerMode("paste")}
>
Prefer pasting? Paste link
</button>
</div>
)}
</>
)}
{tab === "nsec" && (
<div className="login-method">
<p className="login-method-desc">
Paste your <strong>nsec</strong> to sign in (kept in this tab only). Npub alone cannot sign.
</p>
<textarea
className="login-modal-textarea"
placeholder="nsec1… or npub1…"
value={nsecNpubInput}
onChange={(e) => setNsecNpubInput(e.target.value)}
rows={3}
disabled={loading}
/>
<button
type="button"
className="login-method-btn"
onClick={handleNsecNpub}
disabled={loading}
>
{loading ? "Signing in…" : "Login with nsec"}
</button>
</div>
)}
{error && <p className="login-modal-error" role="alert">{error}</p>}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useCallback, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
/** If true, do not close on overlay click (e.g. when loading). */
preventClose?: boolean;
}
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])";
function getFocusables(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hasAttribute("disabled") && el.offsetParent != null
);
}
export function Modal({ open, onClose, title, children, preventClose }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && !preventClose) onClose();
},
[onClose, preventClose]
);
useEffect(() => {
if (!open) return;
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [open, handleEscape]);
useEffect(() => {
if (!open || !modalRef.current) return;
const focusables = getFocusables(modalRef.current);
const first = focusables[0];
if (first) first.focus();
}, [open]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key !== "Tab" || !modalRef.current) return;
const focusables = getFocusables(modalRef.current);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const target = e.target as HTMLElement;
if (e.shiftKey) {
if (target === first) {
e.preventDefault();
last.focus();
}
} else {
if (target === last) {
e.preventDefault();
first.focus();
}
}
},
[]
);
const handleOverlayClick = () => {
if (!preventClose) onClose();
};
if (!open) return null;
const duration = reduceMotion ? 0 : 0.2;
return (
<motion.div
className="modal-overlay"
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration }}
>
<motion.div
ref={modalRef}
className="modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration }}
>
{(title != null || !preventClose) && (
<div className="modal-header">
{title != null && (
<h2 id="modal-title" className="modal-title">
{title}
</h2>
)}
{!preventClose && (
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
×
</button>
)}
</div>
)}
<div className="modal-body">{children}</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,189 @@
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { Countdown } from "./Countdown";
import { CountUpNumber } from "./CountUpNumber";
import type { QuoteResult } from "../api";
const SLOT_DURATION_MS = 750;
const QUOTE_TTL_SECONDS = 60;
interface PayoutCardConfig {
minSats: number;
maxSats: number;
}
interface PayoutCardProps {
config: PayoutCardConfig;
quote: QuoteResult | null;
expired: boolean;
onRecheck: () => void;
}
function useReducedMotionOrDefault(): boolean {
if (typeof window === "undefined") return false;
try {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
} catch {
return false;
}
}
export function PayoutCard({ config, quote, expired, onRecheck }: PayoutCardProps) {
const [slotPhase, setSlotPhase] = useState<"idle" | "spinning" | "locked">("idle");
const [slotValue, setSlotValue] = useState(0);
const reduceMotion = useReducedMotionOrDefault();
const maxSats = Math.max(1, config.maxSats || 5);
const minSats = Math.max(1, config.minSats || 1);
const tiers = Array.from({ length: maxSats - minSats + 1 }, (_, i) => minSats + i);
const hasQuote = quote != null && !expired;
const payoutSats = quote?.payout_sats ?? 0;
const expiresAt = quote?.expires_at ?? 0;
const slotDuration = reduceMotion ? 0 : SLOT_DURATION_MS;
const hasAnimatedRef = useRef(false);
useEffect(() => {
if (!hasQuote || payoutSats < minSats || payoutSats > maxSats) {
setSlotPhase("idle");
setSlotValue(0);
hasAnimatedRef.current = false;
return;
}
if (hasAnimatedRef.current) {
setSlotPhase("locked");
setSlotValue(payoutSats);
return;
}
hasAnimatedRef.current = true;
setSlotPhase("spinning");
setSlotValue(0);
if (slotDuration <= 0) {
setSlotPhase("locked");
setSlotValue(payoutSats);
return;
}
const interval = setInterval(() => {
setSlotValue((v) => (v >= maxSats ? minSats : v + 1));
}, 50);
const timeout = setTimeout(() => {
clearInterval(interval);
setSlotPhase("locked");
setSlotValue(payoutSats);
}, slotDuration);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [hasQuote, payoutSats, minSats, maxSats, slotDuration]);
if (expired && quote) {
return (
<motion.div
className="payout-card payout-card-expired"
initial={false}
animate={{ opacity: 1 }}
>
<p className="payout-card-expired-label">Quote expired</p>
<button type="button" className="payout-card-recheck-btn" onClick={onRecheck}>
<span className="payout-card-recheck-icon" aria-hidden></span>
Re-check
</button>
</motion.div>
);
}
if (hasQuote && slotPhase !== "idle") {
return (
<PayoutCardLocked
quote={quote}
expiresAt={expiresAt}
slotPhase={slotPhase}
slotValue={slotValue}
payoutSats={payoutSats}
reduceMotion={reduceMotion}
/>
);
}
return (
<motion.div className="payout-card payout-card-potential" layout>
<p className="payout-card-potential-label">Potential range</p>
<div className="payout-card-amount-row">
<span className="payout-card-amount-value">{minSats}{maxSats}</span>
<span className="payout-card-amount-unit">sats</span>
</div>
<div className="payout-card-dots" role="img" aria-label={`Payout range ${minSats} to ${maxSats} sats`}>
{tiers.map((i) => (
<div key={i} className="payout-card-dot" />
))}
</div>
</motion.div>
);
}
function PayoutCardLocked({
quote,
expiresAt,
slotPhase,
slotValue,
payoutSats,
reduceMotion,
}: {
quote: QuoteResult;
expiresAt: number;
slotPhase: "spinning" | "locked";
slotValue: number;
payoutSats: number;
reduceMotion: boolean;
}) {
const [secondsLeft, setSecondsLeft] = useState(() =>
Math.max(0, expiresAt - Math.floor(Date.now() / 1000))
);
useEffect(() => {
setSecondsLeft(Math.max(0, expiresAt - Math.floor(Date.now() / 1000)));
const t = setInterval(() => {
setSecondsLeft(Math.max(0, expiresAt - Math.floor(Date.now() / 1000)));
}, 1000);
return () => clearInterval(t);
}, [expiresAt]);
const progressPct = quote.expires_at > 0 ? (secondsLeft / QUOTE_TTL_SECONDS) * 100 : 0;
return (
<motion.div
className="payout-card payout-card-locked"
initial={reduceMotion ? false : { scale: 0.98 }}
animate={{ scale: 1 }}
transition={{ duration: 0.25 }}
>
<div className="payout-card-amount-row">
<span className="payout-card-amount-value">
{slotPhase === "locked" ? (
<CountUpNumber value={payoutSats} durationMs={400} />
) : (
slotValue
)}
</span>
<span className="payout-card-amount-unit">sats</span>
</div>
<p className="payout-card-locked-subtitle">
Locked for <Countdown targetUnixSeconds={expiresAt} format="clock" />
</p>
<div
className="payout-card-expiry-bar"
role="progressbar"
aria-valuenow={Math.round(progressPct)}
aria-valuemin={0}
aria-valuemax={100}
>
<motion.div
className="payout-card-expiry-fill"
animate={{ width: `${progressPct}%` }}
transition={{ duration: 1 }}
/>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { SimplePool, generateSecretKey, getPublicKey } from "nostr-tools";
import { BunkerSigner, createNostrConnectURI } from "nostr-tools/nip46";
import { useToast } from "../contexts/ToastContext";
import type { Nip98Signer } from "../api";
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
];
const WAIT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
interface RemoteSignerQRProps {
onConnect: (sign: Nip98Signer) => Promise<void>;
onError: (message: string | null) => void;
disabled?: boolean;
/** When true, show "Signing in…" but keep mounted so the signer pool stays alive. */
signingIn?: boolean;
}
export function RemoteSignerQR({ onConnect, onError, disabled, signingIn }: RemoteSignerQRProps) {
const { showToast } = useToast();
const [connectionUri, setConnectionUri] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copyLabel, setCopyLabel] = useState("Copy");
const [waiting, setWaiting] = useState(false);
const poolRef = useRef<SimplePool | null>(null);
const abortRef = useRef<AbortController | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimeoutRef = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const generateUri = useCallback(() => {
const clientSecret = generateSecretKey();
const clientPubkey = getPublicKey(clientSecret);
const secret = randomHex(32);
const uri = createNostrConnectURI({
clientPubkey,
relays: DEFAULT_RELAYS,
secret,
name: "Sats Faucet",
url: typeof window !== "undefined" ? window.location.origin : "",
});
return { uri, clientSecret };
}, []);
const startWaiting = useCallback(
(uri: string, clientSecret: Uint8Array) => {
clearTimeoutRef();
const pool = new SimplePool();
poolRef.current = pool;
const abort = new AbortController();
abortRef.current = abort;
setWaiting(true);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
if (abortRef.current?.signal.aborted) return;
abortRef.current?.abort();
onError("Connection timed out. Try regenerating the QR code.");
setWaiting(false);
}, WAIT_TIMEOUT_MS);
BunkerSigner.fromURI(clientSecret, uri, { pool }, abort.signal)
.then(async (signer) => {
if (abortRef.current?.signal.aborted) return;
clearTimeoutRef();
setWaiting(false);
const sign: Nip98Signer = (e) => signer.signEvent(e);
try {
await onConnect(sign);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
onError(msg);
}
})
.catch((err) => {
if (abortRef.current?.signal.aborted) return;
clearTimeoutRef();
setWaiting(false);
const msg = err instanceof Error ? err.message : String(err);
onError(msg);
});
},
[onConnect, onError, clearTimeoutRef]
);
const regenerate = useCallback(() => {
clearTimeoutRef();
abortRef.current?.abort();
abortRef.current = null;
if (poolRef.current) {
poolRef.current.destroy();
poolRef.current = null;
}
onError(null);
const { uri, clientSecret } = generateUri();
setConnectionUri(uri);
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
startWaiting(uri, clientSecret);
}, [generateUri, startWaiting, onError, clearTimeoutRef]);
useEffect(() => {
if (disabled) return;
const { uri, clientSecret } = generateUri();
setConnectionUri(uri);
setQrDataUrl(null);
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
startWaiting(uri, clientSecret);
return () => {
clearTimeoutRef();
abortRef.current?.abort();
if (poolRef.current) {
poolRef.current.destroy();
poolRef.current = null;
}
};
}, [disabled, generateUri, startWaiting, clearTimeoutRef]);
const handleCopy = useCallback(() => {
if (!connectionUri) return;
navigator.clipboard.writeText(connectionUri).then(() => {
setCopyLabel("Copied");
showToast("Copied");
setTimeout(() => setCopyLabel("Copy"), 1500);
});
}, [connectionUri, showToast]);
if (disabled) return null;
return (
<div className="remote-signer-qr-content">
<div className="remote-signer-qr-card">
{qrDataUrl ? (
<img src={qrDataUrl} alt="Scan to connect with your signer" />
) : (
<span className="remote-signer-qr-placeholder">Generating QR</span>
)}
</div>
{signingIn ? (
<p className="remote-signer-waiting" role="status">
Signing in
</p>
) : waiting ? (
<p className="remote-signer-waiting" role="status">
Waiting for signer approval
</p>
) : null}
<div className="remote-signer-connection-row">
<input
type="text"
readOnly
value={connectionUri ?? ""}
aria-label="Connection string"
/>
<button
type="button"
className="remote-signer-copy-btn"
onClick={handleCopy}
disabled={!connectionUri}
aria-label="Copy connection string"
>
{copyLabel}
</button>
</div>
<button type="button" className="remote-signer-regenerate-btn" onClick={regenerate}>
Regenerate QR
</button>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import React from "react";
import { motion } from "framer-motion";
import { getStats, type Stats } from "../api";
const REFRESH_MS = 45_000;
export interface StatsSectionProps {
/** Optional refetch trigger: when this value changes, stats are refetched. */
refetchTrigger?: number;
}
function AnimatedNumber({ value }: { value: number }) {
return (
<motion.span
key={value}
initial={{ opacity: 0.6 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{value.toLocaleString()}
</motion.span>
);
}
export function StatsSection({ refetchTrigger }: StatsSectionProps) {
const [stats, setStats] = React.useState<Stats | null>(null);
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const load = React.useCallback(async (userRefresh = false) => {
if (userRefresh) setRefreshing(true);
try {
const s = await getStats();
setStats(s);
} catch {
setStats(null);
} finally {
setLoading(false);
if (userRefresh) setRefreshing(false);
}
}, []);
React.useEffect(() => {
load();
const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t);
}, [load, refetchTrigger]);
if (loading && !stats) {
return (
<div className="stats-box stats-skeleton">
<div className="skeleton-line balance" />
<div className="skeleton-line short" />
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
);
}
if (!stats) return <div className="stats-box"><p>Stats unavailable</p></div>;
const n = (v: number | undefined | null) => Number(v ?? 0);
const ts = (v: number | undefined | null) => new Date(Number(v ?? 0) * 1000).toLocaleString();
const dailyBudget = Number(stats.dailyBudgetSats) || 1;
const budgetUsed = 0; /* API does not expose "spent today" in sats */
const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100);
return (
<div className="stats-box">
<h3>Faucet stats</h3>
<p className="stats-balance">
<AnimatedNumber value={n(stats.balanceSats)} /> sats
</p>
<p className="stats-balance-label">Pool balance</p>
<div className="stats-progress-wrap">
<div className="stats-progress-label">
Daily budget: <AnimatedNumber value={n(stats.dailyBudgetSats)} /> sats
</div>
<div className="stats-progress-bar">
<div className="stats-progress-fill" style={{ width: `${100 - budgetPct}%` }} />
</div>
</div>
<div className="stats-rows">
<div className="stats-row">
<span>Total paid</span>
<span><AnimatedNumber value={n(stats.totalPaidSats)} /> sats</span>
</div>
<div className="stats-row">
<span>Total claims</span>
<span><AnimatedNumber value={n(stats.totalClaims)} /></span>
</div>
<div className="stats-row">
<span>Claims (24h)</span>
<span><AnimatedNumber value={n(stats.claimsLast24h)} /></span>
</div>
</div>
{stats.recentPayouts?.length > 0 && (
<>
<h3 className="stats-recent-title">Recent payouts</h3>
<ul className="stats-recent-list">
{stats.recentPayouts.slice(0, 10).map((p, i) => (
<li key={i}>
{p.pubkey_prefix ?? "…"} {n(p.payout_sats).toLocaleString()} sats {ts(p.claimed_at)}
</li>
))}
</ul>
</>
)}
<button
type="button"
className={`entropy-btn stats-refresh ${refreshing ? "stats-refresh--spinning" : ""}`}
onClick={() => load(true)}
disabled={refreshing}
>
<span className="stats-refresh-icon" aria-hidden></span>
Refresh
</button>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
const STEPS = [
{ step: 1, label: "Connect" },
{ step: 2, label: "Check" },
{ step: 3, label: "Confirm" },
{ step: 4, label: "Receive" },
] as const;
interface StepIndicatorProps {
currentStep: 1 | 2 | 3 | 4;
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
export function StepIndicator({ currentStep }: StepIndicatorProps) {
return (
<div className="step-indicator" role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={4} aria-label={`Step ${currentStep} of 4`}>
<ol className="step-indicator-list">
{STEPS.map(({ step, label }, index) => {
const isCompleted = step < currentStep;
const isCurrent = step === currentStep;
const isFuture = step > currentStep;
return (
<li
key={step}
className={`step-indicator-item ${isCurrent ? "step-indicator-item--current" : ""} ${isCompleted ? "step-indicator-item--completed" : ""} ${isFuture ? "step-indicator-item--future" : ""}`}
aria-current={isCurrent ? "step" : undefined}
>
{index > 0 && <span className="step-indicator-connector" aria-hidden />}
<span className="step-indicator-marker">
{isCompleted ? <CheckIcon /> : <span className="step-indicator-number">{step}</span>}
</span>
<span className="step-indicator-label">{label}</span>
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from "react";
import { Countdown } from "./Countdown";
import { useToast } from "../contexts/ToastContext";
import type { ConfirmResult } from "../api";
interface SuccessStepProps {
result: ConfirmResult;
onDone: () => void;
onClaimAgain: () => void;
}
function CheckIcon() {
return (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps) {
const { showToast } = useToast();
const amount = result.payout_sats ?? 0;
const copyPaymentHash = () => {
const hash = result.payment_hash;
if (!hash) return;
navigator.clipboard.writeText(hash).then(() => showToast("Copied"));
};
return (
<div className="claim-wizard-step claim-wizard-step-success">
<div className="claim-wizard-success-icon" aria-hidden>
<CheckIcon />
</div>
<h3 className="claim-wizard-step-title">Sats sent</h3>
<p className="claim-wizard-success-amount">
<span className="claim-wizard-success-amount-value">{amount}</span>
<span className="claim-wizard-success-amount-unit"> sats</span>
</p>
{result.payment_hash && (
<div className="claim-wizard-success-payment-hash">
<code className="claim-wizard-payment-hash-code">{result.payment_hash.slice(0, 16)}</code>
<button type="button" className="btn-secondary claim-wizard-copy-btn" onClick={copyPaymentHash}>
Copy hash
</button>
</div>
)}
{result.next_eligible_at != null && (
<p className="claim-wizard-success-next">
Next eligible: <Countdown targetUnixSeconds={result.next_eligible_at} format="duration" />
</p>
)}
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onDone}>
Done
</button>
<button type="button" className="btn-secondary" onClick={onClaimAgain}>
Claim again later
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from "react";
interface ToastProps {
message: string;
visible: boolean;
onDismiss: () => void;
durationMs?: number;
}
export function Toast({ message, visible, onDismiss, durationMs = 2500 }: ToastProps) {
useEffect(() => {
if (!visible || !message) return;
const t = setTimeout(onDismiss, durationMs);
return () => clearTimeout(t);
}, [visible, message, durationMs, onDismiss]);
if (!visible || !message) return null;
return (
<div className="toast" role="status" aria-live="polite">
{message}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { Toast } from "../components/Toast";
interface ToastContextValue {
showToast: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
interface ToastProviderProps {
children: ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const [message, setMessage] = useState("");
const [visible, setVisible] = useState(false);
const showToast = useCallback((msg: string) => {
setMessage(msg);
setVisible(true);
}, []);
const onDismiss = useCallback(() => setVisible(false), []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<Toast message={message} visible={visible} onDismiss={onDismiss} />
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,182 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { postClaimQuote, postClaimConfirm, type QuoteResult, type ConfirmResult, type ApiError } from "../api";
export type ClaimLoadingState = "idle" | "quote" | "confirm";
/** Single claim flow state for UI; disconnected is inferred by caller when !pubkey */
export type ClaimFlowState =
| "connected_idle"
| "quoting"
| "quote_ready"
| "confirming"
| "success"
| "denied"
| "error";
export const ELIGIBILITY_PROGRESS_STEPS = [
"Verifying Nostr signature",
"Checking cooldown",
"Calculating payout",
"Locking in amount",
] as const;
export const ELIGIBILITY_MIN_MS = 900;
export interface DenialState {
message: string;
next_eligible_at?: number;
/** From API error code; used in "Why?" expandable */
code?: string;
}
export interface ConfirmErrorState {
message: string;
allowRetry: boolean;
}
export interface UseClaimFlowResult {
quote: QuoteResult | null;
success: ConfirmResult | null;
loading: ClaimLoadingState;
denial: DenialState | null;
confirmError: ConfirmErrorState | null;
/** Derived state for UI; use with pubkey to infer disconnected */
claimState: ClaimFlowState;
/** 0-3 during quoting; null otherwise */
eligibilityProgressStep: number | null;
checkEligibility: (lightningAddress: string) => Promise<void>;
confirmClaim: () => Promise<void>;
cancelQuote: () => void;
resetSuccess: () => void;
clearDenial: () => void;
clearConfirmError: () => void;
}
const RETRY_ALLOWED_CODES = new Set(["payout_failed"]);
function isQuoteExpired(quote: QuoteResult | null): boolean {
if (!quote) return true;
return quote.expires_at <= Math.floor(Date.now() / 1000);
}
export function useClaimFlow(): UseClaimFlowResult {
const [quote, setQuote] = useState<QuoteResult | null>(null);
const [success, setSuccess] = useState<ConfirmResult | null>(null);
const [loading, setLoading] = useState<ClaimLoadingState>("idle");
const [denial, setDenial] = useState<DenialState | null>(null);
const [confirmError, setConfirmError] = useState<ConfirmErrorState | null>(null);
const [eligibilityProgressStep, setEligibilityProgressStep] = useState<number | null>(null);
const quoteIdRef = useRef<string | null>(null);
const claimState: ClaimFlowState = useMemo(() => {
if (success) return "success";
if (confirmError) return "error";
if (denial) return "denied";
if (loading === "confirm") return "confirming";
if (loading === "quote") return "quoting";
if (quote && !isQuoteExpired(quote)) return "quote_ready";
return "connected_idle";
}, [success, confirmError, denial, loading, quote]);
const checkEligibility = useCallback(async (lightningAddress: string) => {
const addr = lightningAddress.trim();
if (!/^[^@]+@[^@]+$/.test(addr)) {
setDenial({ message: "Enter a valid Lightning address (user@domain)." });
return;
}
setDenial(null);
setConfirmError(null);
setQuote(null);
quoteIdRef.current = null;
setLoading("quote");
setEligibilityProgressStep(0);
const stepInterval = setInterval(() => {
setEligibilityProgressStep((s) => (s === null ? 0 : Math.min(3, s + 1)));
}, 300);
const apiPromise = postClaimQuote(addr);
const minDelay = new Promise<void>((r) => setTimeout(r, ELIGIBILITY_MIN_MS));
try {
const q = await Promise.all([apiPromise, minDelay]).then(([result]) => result);
if (q?.quote_id && q?.expires_at && typeof q.payout_sats === "number") {
quoteIdRef.current = q.quote_id;
setQuote(q);
} else {
setDenial({ message: "Invalid quote from server. Please try again." });
}
} catch (e) {
const err = e as ApiError;
setDenial({
message: err.message ?? "Request failed",
next_eligible_at: err.next_eligible_at,
code: err.code,
});
} finally {
clearInterval(stepInterval);
setEligibilityProgressStep(null);
setLoading("idle");
}
}, []);
const confirmClaim = useCallback(async () => {
const idToConfirm = quote?.quote_id ?? quoteIdRef.current;
if (!idToConfirm) return;
setConfirmError(null);
setLoading("confirm");
try {
const result = await postClaimConfirm(idToConfirm);
if (result?.success) {
setSuccess(result);
setQuote(null);
quoteIdRef.current = null;
} else {
setConfirmError({
message: result?.message ?? "Claim was not completed.",
allowRetry: false,
});
}
} catch (e) {
const err = e as ApiError;
const message = err.details
? `${err.message ?? "Payment failed"}: ${err.details}`
: (err.message ?? "Payment failed");
setConfirmError({
message,
allowRetry: RETRY_ALLOWED_CODES.has(err.code),
});
} finally {
setLoading("idle");
}
}, [quote]);
const cancelQuote = useCallback(() => {
quoteIdRef.current = null;
setQuote(null);
setConfirmError(null);
}, []);
const resetSuccess = useCallback(() => {
setSuccess(null);
}, []);
const clearDenial = useCallback(() => setDenial(null), []);
const clearConfirmError = useCallback(() => setConfirmError(null), []);
return {
quote,
success,
loading,
denial,
confirmError,
claimState,
eligibilityProgressStep,
checkEligibility,
confirmClaim,
cancelQuote,
resetSuccess,
clearDenial,
clearConfirmError,
};
}

21
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import "./styles/global.css";
const rootEl = document.getElementById("root");
if (!rootEl) {
document.body.innerHTML = "<p>Root element #root not found.</p>";
} else {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<ErrorBoundary>
<ToastProvider>
<App />
</ToastProvider>
</ErrorBoundary>
</React.StrictMode>
);
}

View File

@@ -0,0 +1,142 @@
import { useState, useEffect, useMemo } from "react";
import { getStats, type Stats, type DepositSource } from "../api";
type TxDirection = "in" | "out";
type TxType = "lightning" | "cashu";
interface UnifiedTx {
at: number;
direction: TxDirection;
type: TxType;
amount_sats: number;
details: string;
}
function formatSource(s: DepositSource): TxType {
return s === "cashu" ? "cashu" : "lightning";
}
export function TransactionsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getStats()
.then((s) => {
if (!cancelled) setStats(s);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const n = (v: number | undefined | null) => Number(v ?? 0).toLocaleString();
/** Display amount: backend may have stored incoming Lightning in msats; show sats. */
const displaySats = (tx: UnifiedTx): number => {
const a = tx.amount_sats;
if (tx.direction === "in" && tx.type === "lightning" && a >= 1000) return Math.floor(a / 1000);
return a;
};
const formatDate = (ts: number) => {
if (!ts || ts < 1e9) return "—";
return new Date(ts * 1000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
};
const transactions = useMemo((): UnifiedTx[] => {
if (!stats) return [];
const payouts = (stats.recentPayouts ?? []).map((p) => ({
at: p.claimed_at,
direction: "out" as TxDirection,
type: "lightning" as TxType,
amount_sats: p.payout_sats,
details: p.pubkey_prefix ?? "—",
}));
const deposits = (stats.recentDeposits ?? []).map((d) => ({
at: Number(d.created_at) || 0,
direction: "in" as TxDirection,
type: formatSource(d.source),
amount_sats: d.amount_sats,
details: d.source === "cashu" ? "Cashu redeem" : "Lightning",
}));
const merged = [...payouts, ...deposits].sort((a, b) => b.at - a.at);
return merged.slice(0, 50);
}, [stats]);
return (
<div className="transactions-page">
<h1 className="transactions-title">Transactions</h1>
<p className="transactions-intro">
Incoming (deposits) and outgoing (faucet payouts). Lightning and Cashu.
</p>
{loading && (
<div className="transactions-loading">
<div className="stats-skeleton">
<div className="skeleton-line balance" />
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
</div>
)}
{error && (
<div className="transactions-error">
<p>{error}</p>
</div>
)}
{!loading && !error && (
<div className="transactions-box">
{transactions.length === 0 ? (
<p className="transactions-empty">No transactions yet.</p>
) : (
<table className="transactions-table">
<thead>
<tr>
<th>Date</th>
<th>Direction</th>
<th>Type</th>
<th>Amount</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{transactions.map((tx, i) => (
<tr key={i}>
<td>{formatDate(tx.at)}</td>
<td>
<span className={`transactions-direction transactions-direction--${tx.direction}`}>
{tx.direction === "in" ? "In" : "Out"}
</span>
</td>
<td>
<span className={`transactions-type transactions-type--${tx.type}`}>
{tx.type === "cashu" ? "Cashu" : "Lightning"}
</span>
</td>
<td className="transactions-amount">{n(displaySats(tx))} sats</td>
<td className="transactions-details">{tx.details}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
}

6
frontend/src/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "qrcode" {
export function toDataURL(
text: string,
options?: { width?: number; margin?: number }
): Promise<string>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/**
* Validation helpers for remote signer input (bunker URL or NIP-05).
*/
export function isBunkerUrl(str: string): boolean {
const trimmed = str.trim();
if (!trimmed) return false;
return trimmed.toLowerCase().startsWith("bunker://");
}
/**
* NIP-05 style identifier: name@domain with at least one dot in the domain.
*/
export function isNip05(str: string): boolean {
const trimmed = str.trim();
if (!trimmed) return false;
return /^[^@]+@[^@]+\..+$/.test(trimmed);
}
export function isValidRemoteSignerInput(str: string): boolean {
return isBunkerUrl(str) || isNip05(str);
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
"/claim": { target: "http://localhost:3001", changeOrigin: true },
"/auth": { target: "http://localhost:3001", changeOrigin: true },
"/user": { target: "http://localhost:3001", changeOrigin: true },
"/config": { target: "http://localhost:3001", changeOrigin: true },
"/stats": { target: "http://localhost:3001", changeOrigin: true },
"/deposit": { target: "http://localhost:3001", changeOrigin: true },
},
},
});