Add Swagger docs at /docs and /openapi.json; frontend and backend updates

Made-with: Cursor
This commit is contained in:
SatsFaucet
2026-03-01 01:24:51 +01:00
parent bdb4892014
commit 381597c96f
20 changed files with 1214 additions and 98 deletions

View File

@@ -49,6 +49,12 @@ export default function App() {
setLoginMethod(pk ? (method ?? "nip98") : null);
}, []);
const handleLogout = useCallback(() => {
clearToken();
setPubkey(null);
setLoginMethod(null);
}, []);
const handleClaimSuccess = useCallback(() => {
setStatsRefetchTrigger((t) => t + 1);
}, []);
@@ -56,7 +62,7 @@ export default function App() {
return (
<BrowserRouter>
<div className="app">
<Header />
<Header pubkey={pubkey} onLogout={handleLogout} />
<div className="topbar" />
<div className="app-body">
<Routes>

View File

@@ -1,11 +1,15 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { postUserRefreshProfile, type UserProfile, type LoginMethod } from "../api";
import { useClaimFlow } from "../hooks/useClaimFlow";
import { useNostrProfile } from "../hooks/useNostrProfile";
import { StepIndicator } from "./StepIndicator";
import { ConnectStep } from "./ConnectStep";
import { EligibilityStep } from "./EligibilityStep";
import { ConfirmStep } from "./ConfirmStep";
import { SuccessStep } from "./SuccessStep";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { Modal } from "./Modal";
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
@@ -13,16 +17,6 @@ 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;
loginMethod: LoginMethod | null;
@@ -34,41 +28,60 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
const [profile, setProfile] = useState<UserProfile | null>(null);
const [lightningAddress, setLightningAddress] = useState("");
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
const [claimModalOpen, setClaimModalOpen] = useState(false);
const wizardRef = useRef<HTMLDivElement>(null);
const autoCheckRef = useRef<string | null>(null);
const claim = useClaimFlow();
const nostrProfile = useNostrProfile(pubkey);
const currentStep = useMemo(
() => getWizardStep(!!pubkey, claim.claimState),
[pubkey, claim.claimState]
);
const isConnected = !!pubkey;
const isBusy = claim.loading !== "idle";
const hasResult = !!claim.quote || !!claim.denial || !!claim.success || !!claim.confirmError;
useEffect(() => {
if (!pubkey) {
setProfile(null);
setLightningAddress("");
autoCheckRef.current = null;
return;
}
postUserRefreshProfile()
.then((p) => {
setProfile(p);
const addr = (p.lightning_address ?? "").trim();
setLightningAddress(addr);
if (addr) setLightningAddress(addr);
})
.catch(() => setProfile(null));
}, [pubkey]);
useEffect(() => {
if (currentStep === 2 && pubkey && wizardRef.current) {
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
if (!nostrProfile?.lud16 || !pubkey) return;
const relayAddr = nostrProfile.lud16.trim();
if (relayAddr && isValidLightningAddress(relayAddr)) {
setLightningAddress(relayAddr);
}
}, [currentStep, pubkey]);
}, [nostrProfile, pubkey]);
useEffect(() => {
if (currentStep === 4 && wizardRef.current) {
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
if (
!pubkey ||
autoCheckRef.current === pubkey ||
claim.loading !== "idle" ||
claim.claimState !== "connected_idle"
) return;
const addr = lightningAddress.trim();
if (!addr || !isValidLightningAddress(addr)) return;
autoCheckRef.current = pubkey;
setClaimModalOpen(true);
claim.checkEligibility(addr);
}, [pubkey, lightningAddress, claim.loading, claim.claimState, claim.checkEligibility]);
useEffect(() => {
if (isBusy || hasResult) {
setClaimModalOpen(true);
}
}, [currentStep]);
}, [isBusy, hasResult]);
const handleDisconnect = () => {
onPubkeyChange(null);
@@ -77,10 +90,12 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
claim.resetSuccess();
claim.clearDenial();
claim.clearConfirmError();
setClaimModalOpen(false);
};
const handleDone = () => {
claim.resetSuccess();
setClaimModalOpen(false);
onClaimSuccess?.();
};
@@ -89,26 +104,59 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
claim.clearDenial();
claim.clearConfirmError();
setLightningAddressTouched(false);
// Stay on step 2 (eligibility)
setClaimModalOpen(false);
};
const handleCheckEligibility = () => {
setClaimModalOpen(true);
claim.checkEligibility(lightningAddress);
};
const handleCancelQuote = () => {
claim.cancelQuote();
claim.clearConfirmError();
setClaimModalOpen(false);
};
const handleCloseModal = useCallback(() => {
if (claim.loading !== "idle") return;
claim.cancelQuote();
claim.clearDenial();
claim.clearConfirmError();
claim.resetSuccess();
setClaimModalOpen(false);
}, [claim]);
const handleDismissDenial = () => {
claim.clearDenial();
setClaimModalOpen(false);
};
const handleCheckAgain = () => {
claim.clearDenial();
setClaimModalOpen(false);
};
const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const fromProfile =
Boolean(profile?.lightning_address) &&
lightningAddress.trim() === (profile?.lightning_address ?? "").trim();
(Boolean(profile?.lightning_address) &&
lightningAddress.trim() === (profile?.lightning_address ?? "").trim()) ||
(Boolean(nostrProfile?.lud16) &&
lightningAddress.trim() === (nostrProfile?.lud16 ?? "").trim());
const quoteExpired =
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
const modalTitle = useMemo(() => {
if (claim.success) return "Sats sent!";
if (claim.confirmError) return "Something went wrong";
if (claim.loading === "confirm") return "Sending sats…";
if (claim.quote) return "Confirm payout";
if (claim.denial) return "Not eligible";
if (claim.loading === "quote") return "Checking eligibility";
return "Claim";
}, [claim.success, claim.confirmError, claim.loading, claim.quote, claim.denial]);
return (
<div className="content claim-wizard-content" ref={wizardRef}>
<div className="ClaimWizard claim-wizard-root">
@@ -126,20 +174,18 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
</button>
)}
</div>
<StepIndicator currentStep={currentStep} />
<StepIndicator currentStep={isConnected ? 2 : 1} />
</header>
<div className="claim-wizard-body">
{currentStep === 1 && (
{!isConnected ? (
<ConnectStep
pubkey={pubkey}
displayName={profile?.name}
onConnect={(pk, method) => onPubkeyChange(pk, method)}
onDisconnect={handleDisconnect}
/>
)}
{currentStep === 2 && (
) : (
<EligibilityStep
lightningAddress={lightningAddress}
onLightningAddressChange={setLightningAddress}
@@ -149,17 +195,37 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
fromProfile={fromProfile}
readOnly={loginMethod === "npub"}
loading={claim.loading === "quote"}
eligibilityProgressStep={claim.eligibilityProgressStep}
denial={claim.denial}
onCheckEligibility={handleCheckEligibility}
onClearDenial={claim.clearDenial}
onCheckAgain={() => {
claim.clearDenial();
}}
/>
)}
</div>
</div>
<Modal
open={claimModalOpen}
onClose={handleCloseModal}
title={modalTitle}
preventClose={claim.loading !== "idle"}
>
<div className="claim-modal-body">
{claim.loading === "quote" && (
<div className="claim-modal-progress" role="status" aria-live="polite">
<div className="claim-wizard-progress-bar" />
<p className="claim-modal-progress-text">
{ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]}
</p>
</div>
)}
{claim.denial && (
<ClaimDenialPanel
denial={claim.denial}
onDismiss={handleDismissDenial}
onCheckAgain={handleCheckAgain}
/>
)}
{currentStep === 3 && claim.quote && (
{(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && (
<ConfirmStep
quote={claim.quote}
lightningAddress={lightningAddress}
@@ -172,7 +238,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
/>
)}
{currentStep === 4 && claim.success && (
{claim.success && (
<SuccessStep
result={claim.success}
onDone={handleDone}
@@ -180,7 +246,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
/>
)}
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,7 +1,4 @@
import { useState } from "react";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import type { DenialState } from "../hooks/useClaimFlow";
interface EligibilityStepProps {
lightningAddress: string;
@@ -12,11 +9,7 @@ interface EligibilityStepProps {
fromProfile: boolean;
readOnly?: boolean;
loading: boolean;
eligibilityProgressStep: number | null;
denial: DenialState | null;
onCheckEligibility: () => void;
onClearDenial: () => void;
onCheckAgain?: () => void;
}
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
@@ -30,11 +23,7 @@ export function EligibilityStep({
fromProfile,
readOnly,
loading,
eligibilityProgressStep,
denial,
onCheckEligibility,
onClearDenial,
onCheckAgain,
}: EligibilityStepProps) {
const [editing, setEditing] = useState(false);
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
@@ -94,14 +83,7 @@ export function EligibilityStep({
</p>
)}
{noAddressForNpub ? null : 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>
) : (
{!noAddressForNpub && (
<div className="claim-wizard-step-actions">
<button
type="button"
@@ -109,20 +91,10 @@ export function EligibilityStep({
onClick={onCheckEligibility}
disabled={!canCheck}
>
Check eligibility
{loading ? "Checking…" : "Check eligibility"}
</button>
</div>
)}
{denial && (
<div className="claim-wizard-denial-wrap">
<ClaimDenialPanel
denial={denial}
onDismiss={onClearDenial}
onCheckAgain={onCheckAgain}
/>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,50 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Link, useLocation } from "react-router-dom";
import { useNostrProfile } from "../hooks/useNostrProfile";
import { nip19 } from "nostr-tools";
export function Header() {
interface HeaderProps {
pubkey: string | null;
onLogout?: () => void;
}
function truncatedNpub(pubkey: string): string {
const npub = nip19.npubEncode(pubkey);
return npub.slice(0, 12) + "..." + npub.slice(-4);
}
export function Header({ pubkey, onLogout }: HeaderProps) {
const location = useLocation();
const profile = useNostrProfile(pubkey);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null);
const handleToggle = useCallback(() => setMenuOpen((o) => !o), []);
useEffect(() => {
if (!menuOpen) return;
function onClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
function onEscape(e: KeyboardEvent) {
if (e.key === "Escape") setMenuOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onEscape);
return () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onEscape);
};
}, [menuOpen]);
const handleLogout = () => {
setMenuOpen(false);
onLogout?.();
};
return (
<header className="site-header">
@@ -9,20 +52,67 @@ export function Header() {
<Link to="/" className="site-logo">
<span className="site-logo-text">Sats Faucet</span>
</Link>
<nav className="site-nav" aria-label="Main navigation">
<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 className="site-header-right">
<nav className="site-nav" aria-label="Main navigation">
<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>
{pubkey && (
<div className="header-user" ref={menuRef}>
<button
type="button"
className="header-user-trigger"
onClick={handleToggle}
aria-expanded={menuOpen}
aria-haspopup="true"
>
{profile?.picture ? (
<img
src={profile.picture}
alt=""
className="header-user-avatar"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="header-user-avatar header-user-avatar--placeholder">
{(displayName?.[0] ?? "?").toUpperCase()}
</span>
)}
<span className="header-user-name">{displayName}</span>
<svg className={`header-user-chevron${menuOpen ? " open" : ""}`} width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{menuOpen && (
<div className="header-user-menu" role="menu">
<div className="header-user-menu-info">
<span className="header-user-menu-name">{profile?.display_name || profile?.name || "Nostr User"}</span>
<span className="header-user-menu-npub">{truncatedNpub(pubkey)}</span>
</div>
<div className="header-user-menu-divider" />
<button type="button" className="header-user-menu-item" role="menuitem" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Log out
</button>
</div>
)}
</div>
)}
</div>
</div>
</header>
);

View File

@@ -0,0 +1,62 @@
import { useState, useEffect, useRef } from "react";
import { SimplePool } from "nostr-tools";
export interface NostrProfile {
name?: string;
display_name?: string;
picture?: string;
about?: string;
nip05?: string;
lud16?: string;
}
const RELAYS = (import.meta.env.VITE_NOSTR_RELAYS as string || "wss://relay.damus.io,wss://nos.lol")
.split(",")
.map((r: string) => r.trim())
.filter(Boolean);
const cache = new Map<string, NostrProfile>();
export function useNostrProfile(pubkey: string | null): NostrProfile | null {
const [profile, setProfile] = useState<NostrProfile | null>(() =>
pubkey ? cache.get(pubkey) ?? null : null,
);
const poolRef = useRef<SimplePool | null>(null);
useEffect(() => {
if (!pubkey) {
setProfile(null);
return;
}
const cached = cache.get(pubkey);
if (cached) {
setProfile(cached);
return;
}
const pool = new SimplePool();
poolRef.current = pool;
let cancelled = false;
pool
.get(RELAYS, { kinds: [0], authors: [pubkey], limit: 1 })
.then((ev) => {
if (cancelled || !ev) return;
try {
const meta = JSON.parse(ev.content) as NostrProfile;
cache.set(pubkey, meta);
setProfile(meta);
} catch { /* malformed content */ }
})
.catch(() => {});
return () => {
cancelled = true;
pool.close(RELAYS);
poolRef.current = null;
};
}, [pubkey]);
return profile;
}

View File

@@ -102,6 +102,149 @@ body {
background: rgba(249, 115, 22, 0.1);
}
/* Header layout: right side groups nav + user */
.site-header-right {
display: flex;
align-items: center;
gap: 16px;
}
.header-user {
position: relative;
padding-left: 16px;
border-left: 1px solid var(--border);
}
.header-user-trigger {
display: flex;
align-items: center;
gap: 10px;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
transition: background 0.15s;
color: var(--text);
}
.header-user-trigger:hover {
background: var(--bg-card-hover);
}
.header-user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--border);
}
.header-user-avatar--placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-card-hover);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.header-user-name {
font-size: 14px;
font-weight: 500;
color: var(--text);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-user-chevron {
color: var(--text-muted);
transition: transform 0.2s;
flex-shrink: 0;
}
.header-user-chevron.open {
transform: rotate(180deg);
}
/* User dropdown menu */
.header-user-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
min-width: 200px;
z-index: 100;
overflow: hidden;
animation: menu-in 0.15s ease-out;
}
@keyframes menu-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.header-user-menu-info {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
.header-user-menu-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.header-user-menu-npub {
font-size: 12px;
color: var(--text-muted);
font-family: monospace;
}
.header-user-menu-divider {
height: 1px;
background: var(--border);
}
.header-user-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 16px;
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.header-user-menu-item:hover {
background: var(--bg-card-hover);
color: var(--error);
}
/* Claim modal body */
.claim-modal-body {
padding: 24px;
}
.claim-modal-body .claim-wizard-step {
padding: 0;
}
.claim-modal-body .claim-denial-panel {
margin: 0;
border: none;
box-shadow: none;
background: transparent;
padding: 0;
}
.claim-modal-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px 0;
}
.claim-modal-progress-text {
font-size: 14px;
color: var(--text-muted);
}
.topbar {
background: linear-gradient(90deg, var(--accent) 0%, #fb923c 100%);
height: 4px;
@@ -2221,6 +2364,10 @@ h1 {
.site-header { padding: 0 16px; }
.site-header-inner { height: 52px; }
.site-nav-link { padding: 8px 12px; font-size: 13px; }
.header-user-name { display: none; }
.header-user-chevron { display: none; }
.header-user { padding-left: 12px; }
.header-user-trigger { gap: 0; padding: 4px; }
.site-footer { padding: 20px 16px 24px; }
.site-footer-nav { flex-wrap: wrap; justify-content: center; gap: 16px; }