Production-ready overhaul: backend fixes, claim flow polish, SEO, mobile, nginx

Backend:
- Fix activity score (followersCount check), NIP-98 URL proto, wallet balance guard
- Add payment_hash to confirm response, spentTodaySats to stats
- Add idx_claims_ip_hash; security headers, graceful shutdown, periodic nonce cleanup

Frontend:
- Remove 8 legacy components; polish wizard (rules summary, profile card, confetti, share)
- Stats budget bar uses spentTodaySats; ErrorBoundary with reload

SEO & production:
- Full meta/OG/Twitter, favicon, JSON-LD, robots.txt, sitemap.xml
- Mobile CSS fixes; nginx static dist + gzip + cache + security headers
- Vite manualChunks; aria-labels, dynamic page titles

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-27 16:29:37 -03:00
parent f31bbb12ab
commit 5b516f02cb
32 changed files with 432 additions and 927 deletions

View File

@@ -46,5 +46,6 @@ CREATE TABLE IF NOT EXISTS nonces (
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey); CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at); CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_claims_ip_hash ON claims(ip_hash);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at); CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status); CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);

View File

@@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS deposits (
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey); CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at); CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_claims_ip_hash ON claims(ip_hash);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at); CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status); CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at); CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);

View File

@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS deposits (
CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey); CREATE INDEX IF NOT EXISTS idx_claims_pubkey ON claims(pubkey);
CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at); CREATE INDEX IF NOT EXISTS idx_claims_claimed_at ON claims(claimed_at);
CREATE INDEX IF NOT EXISTS idx_claims_ip_hash ON claims(ip_hash);
CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at); CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status); CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at); CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);

View File

@@ -9,6 +9,8 @@ import authRoutes from "./routes/auth.js";
import claimRoutes from "./routes/claim.js"; import claimRoutes from "./routes/claim.js";
import userRoutes from "./routes/user.js"; import userRoutes from "./routes/user.js";
const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
async function main() { async function main() {
const db = getDb(); const db = getDb();
await db.runMigrations(); await db.runMigrations();
@@ -17,6 +19,14 @@ async function main() {
const app = express(); const app = express();
if (config.trustProxy) app.set("trust proxy", 1); if (config.trustProxy) app.set("trust proxy", 1);
app.use(express.json({ limit: "10kb" })); app.use(express.json({ limit: "10kb" }));
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
next();
});
app.use( app.use(
cors({ cors({
origin: (origin, cb) => { origin: (origin, cb) => {
@@ -50,12 +60,34 @@ async function main() {
userRoutes userRoutes
); );
app.listen(config.port, () => { const server = app.listen(config.port, () => {
console.log(`Faucet API listening on port ${config.port}`); console.log(`Faucet API listening on port ${config.port}`);
if (config.lnbitsBaseUrl && config.lnbitsAdminKey) { if (config.lnbitsBaseUrl && config.lnbitsAdminKey) {
startLnbitsDepositSync(); startLnbitsDepositSync();
} }
}); });
const nonceCleanupTimer = setInterval(() => {
db.deleteExpiredNonces().catch((err) =>
console.error("[nonce-cleanup] Failed:", err instanceof Error ? err.message : err)
);
}, NONCE_CLEANUP_INTERVAL_MS);
function shutdown(signal: string) {
console.log(`\n[${signal}] Shutting down gracefully…`);
clearInterval(nonceCleanupTimer);
server.close(() => {
console.log("[shutdown] HTTP server closed.");
process.exit(0);
});
setTimeout(() => {
console.error("[shutdown] Forceful exit after timeout.");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
} }
main().catch((err) => { main().catch((err) => {

View File

@@ -83,8 +83,8 @@ export async function nip98Auth(req: Request, res: Response, next: NextFunction)
} }
// Reconstruct absolute URL (protocol + host + path + query) // Reconstruct absolute URL (protocol + host + path + query)
const proto = req.headers["x-forwarded-proto"] ?? (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http"; const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? ((req.socket as { encrypted?: boolean }).encrypted ? "https" : "http");
const host = req.headers["x-forwarded-host"] ?? req.headers.host ?? ""; const host = (req.headers["x-forwarded-host"] as string | undefined) ?? req.headers.host ?? "";
const path = req.originalUrl ?? req.url; const path = req.originalUrl ?? req.url;
const absoluteUrl = `${proto}://${host}${path}`; const absoluteUrl = `${proto}://${host}${path}`;
if (u !== absoluteUrl) { if (u !== absoluteUrl) {

View File

@@ -140,6 +140,7 @@ router.post("/confirm", authOrNip98, async (req: Request, res: Response) => {
res.json({ res.json({
success: true, success: true,
payout_sats: quote.payout_sats, payout_sats: quote.payout_sats,
payment_hash: paymentHash,
next_eligible_at: cooldownEnd, next_eligible_at: cooldownEnd,
}); });
} catch (err) { } catch (err) {

View File

@@ -24,11 +24,14 @@ router.get("/config", (_req: Request, res: Response) => {
router.get("/stats", async (_req: Request, res: Response) => { router.get("/stats", async (_req: Request, res: Response) => {
try { try {
const db = getDb(); const db = getDb();
const [balance, totalPaid, totalClaims, claims24h, recent, recentDeposits] = await Promise.all([ const now = Math.floor(Date.now() / 1000);
const dayStart = now - (now % 86400);
const [balance, totalPaid, totalClaims, claims24h, spentToday, recent, recentDeposits] = await Promise.all([
getWalletBalanceSats().catch(() => 0), getWalletBalanceSats().catch(() => 0),
db.getTotalPaidSats(), db.getTotalPaidSats(),
db.getTotalClaimsCount(), db.getTotalClaimsCount(),
db.getClaimsCountSince(Math.floor(Date.now() / 1000) - 86400), db.getClaimsCountSince(now - 86400),
db.getPaidSatsSince(dayStart),
db.getRecentPayouts(20), db.getRecentPayouts(20),
db.getRecentDeposits(20), db.getRecentDeposits(20),
]); ]);
@@ -38,6 +41,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
totalClaims, totalClaims,
claimsLast24h: claims24h, claimsLast24h: claims24h,
dailyBudgetSats: config.dailyBudgetSats, dailyBudgetSats: config.dailyBudgetSats,
spentTodaySats: spentToday,
recentPayouts: recent, recentPayouts: recent,
recentDeposits, recentDeposits,
}); });

View File

@@ -54,7 +54,7 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise<
}; };
} }
if (balanceSats < config.faucetMinSats) { if (balanceSats < config.minWalletBalanceSats) {
return { return {
eligible: false, eligible: false,
denialCode: "insufficient_balance", denialCode: "insufficient_balance",

View File

@@ -101,7 +101,8 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
if (hasMetadata) score += 10; if (hasMetadata) score += 10;
if (notesInLookback >= config.minNotesCount) score += 20; if (notesInLookback >= config.minNotesCount) score += 20;
if (followingCount >= config.minFollowingCount) score += 10; if (followingCount >= config.minFollowingCount) score += 10;
if (0 >= config.minFollowersCount) score += 10; // followers not fetched for MVP; treat as 0 const followersCount = 0; // followers not fetched for MVP
if (followersCount >= config.minFollowersCount) score += 10;
let lightning_address: string | null = null; let lightning_address: string | null = null;
let name: string | null = null; let name: string | null = null;

View File

@@ -1,35 +1,67 @@
server { server {
server_name faucet.lnpulse.app; server_name faucet.lnpulse.app;
# No root; all locations are proxied.
# Increase body size if needed
client_max_body_size 10M; client_max_body_size 10M;
# Backend API # Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
application/rss+xml
image/svg+xml;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Backend API — proxy to Node.js
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:3001/; proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 30s;
proxy_connect_timeout 10s;
} }
# Health check passthrough # Health check passthrough
location /health { location = /health {
proxy_pass http://127.0.0.1:3001/health; proxy_pass http://127.0.0.1:3001/health;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
# Frontend (Vite preview server) # Static assets with hashed filenames — long-term cache
location / { location /assets/ {
proxy_pass http://127.0.0.1:5173; alias /var/www/faucet.lnpulse.app/dist/assets/;
proxy_http_version 1.1; expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
proxy_set_header Host $host; # Frontend — serve static files with SPA fallback
proxy_set_header Upgrade $http_upgrade; location / {
proxy_set_header Connection "upgrade"; root /var/www/faucet.lnpulse.app/dist;
try_files $uri $uri/ /index.html;
# Short cache for HTML (re-validate on each visit)
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
} }
listen 443 ssl; # managed by Certbot listen 443 ssl; # managed by Certbot
@@ -37,18 +69,14 @@ server {
ssl_certificate_key /etc/letsencrypt/live/faucet.lnpulse.app/privkey.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/faucet.lnpulse.app/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
} }
server { server {
if ($host = faucet.lnpulse.app) { if ($host = faucet.lnpulse.app) {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} # managed by Certbot } # managed by Certbot
listen 80; listen 80;
server_name faucet.lnpulse.app; server_name faucet.lnpulse.app;
return 404; # managed by Certbot return 404; # managed by Certbot
} }

View File

@@ -3,7 +3,49 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sats Faucet</title> <title>Sats Faucet — Free Bitcoin for Nostr Users</title>
<meta name="description" content="Free Bitcoin sats faucet for Nostr users. Connect your Nostr account, prove your identity, and claim sats via Lightning. Transparent, fair, community-funded." />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#0c1222" />
<link rel="canonical" href="https://faucet.lnpulse.app/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Sats Faucet — Free Bitcoin for Nostr Users" />
<meta property="og:description" content="Claim free sats via Lightning. Connect your Nostr identity, pass eligibility checks, and receive a randomized payout. Community-funded and transparent." />
<meta property="og:url" content="https://faucet.lnpulse.app/" />
<meta property="og:site_name" content="Sats Faucet" />
<meta property="og:image" content="https://faucet.lnpulse.app/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Sats Faucet — Free Bitcoin for Nostr Users" />
<meta name="twitter:description" content="Claim free sats via Lightning. Connect your Nostr identity, pass eligibility checks, and receive a randomized payout." />
<meta name="twitter:image" content="https://faucet.lnpulse.app/og-image.png" />
<!-- Favicon (inline SVG lightning bolt) -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230c1222'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='%23f97316'/%3E%3C/svg%3E" />
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Crect width='180' height='180' rx='36' fill='%230c1222'/%3E%3Cpath d='M100 20L45 100h35l-12 60 58-80H90z' fill='%23f97316'/%3E%3C/svg%3E" />
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Sats Faucet",
"url": "https://faucet.lnpulse.app",
"description": "Free Bitcoin sats faucet for Nostr users. Claim sats via Lightning with your Nostr identity.",
"applicationCategory": "FinanceApplication",
"operatingSystem": "Any",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://faucet.lnpulse.app/sitemap.xml

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://faucet.lnpulse.app/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://faucet.lnpulse.app/transactions</loc>
<changefreq>hourly</changefreq>
<priority>0.7</priority>
</url>
</urlset>

View File

@@ -23,18 +23,62 @@ export class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError && this.state.error) { if (this.state.hasError && this.state.error) {
return ( return (
<div style={{ padding: "2rem", maxWidth: 900, margin: "0 auto", fontFamily: "Arial", color: "#333" }}> <div
<h1 style={{ color: "#c0392b", marginBottom: "1rem" }}>Something went wrong</h1> style={{
<pre style={{ background: "#f7f7f7", padding: "1rem", overflow: "auto", fontSize: 13 }}> minHeight: "100vh",
{this.state.error.message} display: "flex",
</pre> flexDirection: "column",
<button alignItems: "center",
type="button" justifyContent: "center",
onClick={() => this.setState({ hasError: false, error: null })} padding: "2rem",
style={{ marginTop: "1rem", padding: "8px 16px", cursor: "pointer" }} fontFamily: "system-ui, -apple-system, sans-serif",
> background: "#0c1222",
Try again color: "#f1f5f9",
</button> textAlign: "center",
}}
>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>&#9889;</div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "0.75rem" }}>
Something went wrong
</h1>
<p style={{ color: "#94a3b8", maxWidth: 420, marginBottom: "1.5rem", lineHeight: 1.6 }}>
The app encountered an unexpected error. This has been logged.
You can try reloading the page.
</p>
<div style={{ display: "flex", gap: "12px" }}>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: "10px 24px",
background: "#f97316",
color: "#fff",
border: "none",
borderRadius: "8px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
}}
>
Reload page
</button>
<button
type="button"
onClick={() => this.setState({ hasError: false, error: null })}
style={{
padding: "10px 24px",
background: "transparent",
color: "#94a3b8",
border: "1px solid #334155",
borderRadius: "8px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</div> </div>
); );
} }

View File

@@ -53,6 +53,7 @@ export interface Stats {
totalClaims: number; totalClaims: number;
claimsLast24h: number; claimsLast24h: number;
dailyBudgetSats: number; dailyBudgetSats: number;
spentTodaySats: number;
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[]; recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[]; recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
} }

View File

@@ -1,24 +0,0 @@
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

@@ -1,231 +0,0 @@
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

@@ -1,195 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,4 +1,6 @@
import { useEffect, useState } from "react";
import { ConnectNostr } from "./ConnectNostr"; import { ConnectNostr } from "./ConnectNostr";
import { getConfig, type FaucetConfig } from "../api";
interface ConnectStepProps { interface ConnectStepProps {
pubkey: string | null; pubkey: string | null;
@@ -8,6 +10,12 @@ interface ConnectStepProps {
} }
export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: ConnectStepProps) { export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: ConnectStepProps) {
const [config, setConfig] = useState<FaucetConfig | null>(null);
useEffect(() => {
getConfig().then(setConfig).catch(() => {});
}, []);
return ( return (
<div className="claim-wizard-step claim-wizard-step-connect"> <div className="claim-wizard-step claim-wizard-step-connect">
<h3 className="claim-wizard-step-title">Connect your Nostr account</h3> <h3 className="claim-wizard-step-title">Connect your Nostr account</h3>
@@ -22,6 +30,17 @@ export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: Co
onDisconnect={onDisconnect} onDisconnect={onDisconnect}
/> />
</div> </div>
{config && (
<div className="claim-wizard-rules">
<h4 className="claim-wizard-rules-title">Faucet rules</h4>
<ul className="claim-wizard-rules-list">
<li>Payout: <strong>{config.faucetMinSats}&ndash;{config.faucetMaxSats} sats</strong> (random)</li>
<li>Cooldown: <strong>{config.cooldownDays} day{config.cooldownDays !== 1 ? "s" : ""}</strong> between claims</li>
<li>Account age: at least <strong>{config.minAccountAgeDays} days</strong></li>
<li>Activity score: minimum <strong>{config.minActivityScore}</strong></li>
</ul>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,34 +0,0 @@
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

@@ -1,3 +1,4 @@
import { useState } from "react";
import { ClaimDenialPanel } from "./ClaimDenialPanel"; import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow"; import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import type { DenialState } from "../hooks/useClaimFlow"; import type { DenialState } from "../hooks/useClaimFlow";
@@ -33,35 +34,48 @@ export function EligibilityStep({
onClearDenial, onClearDenial,
onCheckAgain, onCheckAgain,
}: EligibilityStepProps) { }: EligibilityStepProps) {
const [editing, setEditing] = useState(false);
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim()); const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
const showProfileCard = fromProfile && !editing;
return ( return (
<div className="claim-wizard-step claim-wizard-step-eligibility"> <div className="claim-wizard-step claim-wizard-step-eligibility">
<h3 className="claim-wizard-step-title">Check eligibility</h3> <h3 className="claim-wizard-step-title">Check eligibility</h3>
<p className="claim-wizard-step-desc"> <p className="claim-wizard-step-desc">
Enter your Lightning address. Well verify cooldown and calculate your payout. Enter your Lightning address. We'll verify cooldown and calculate your payout.
</p> </p>
<div className="claim-wizard-address-row"> {showProfileCard ? (
<label htmlFor="wizard-lightning-address">Lightning address</label> <div className="claim-wizard-profile-card">
<input <div className="claim-wizard-profile-card-icon" aria-hidden>&#9889;</div>
id="wizard-lightning-address" <div className="claim-wizard-profile-card-body">
type="text" <span className="claim-wizard-profile-card-label">Lightning address from profile</span>
value={lightningAddress} <span className="claim-wizard-profile-card-address">{lightningAddress}</span>
onChange={(e) => onLightningAddressChange(e.target.value)} </div>
onBlur={() => setLightningAddressTouched(true)} <button
placeholder="you@wallet.com" type="button"
disabled={loading} className="btn-secondary claim-wizard-profile-card-edit"
readOnly={fromProfile} onClick={() => setEditing(true)}
aria-invalid={invalid || undefined} >
aria-describedby={invalid ? "wizard-lightning-hint" : undefined} Edit
/> </button>
{fromProfile && ( </div>
<span className="claim-wizard-profile-badge" title="From your Nostr profile"> ) : (
From profile <div className="claim-wizard-address-row">
</span> <label htmlFor="wizard-lightning-address">Lightning address</label>
)} <input
</div> id="wizard-lightning-address"
type="text"
value={lightningAddress}
onChange={(e) => onLightningAddressChange(e.target.value)}
onBlur={() => setLightningAddressTouched(true)}
placeholder="you@wallet.com"
disabled={loading}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
/>
</div>
)}
{invalid && ( {invalid && (
<p id="wizard-lightning-hint" className="claim-wizard-input-hint" role="alert"> <p id="wizard-lightning-hint" className="claim-wizard-input-hint" role="alert">
Enter a valid Lightning address (user@domain) Enter a valid Lightning address (user@domain)

View File

@@ -6,7 +6,7 @@ export function Footer() {
return ( return (
<footer className="site-footer"> <footer className="site-footer">
<div className="site-footer-inner"> <div className="site-footer-inner">
<nav className="site-footer-nav"> <nav className="site-footer-nav" aria-label="Footer navigation">
<Link to="/">Home</Link> <Link to="/">Home</Link>
<Link to="/transactions">Transactions</Link> <Link to="/transactions">Transactions</Link>
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer"> <a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">

View File

@@ -9,7 +9,7 @@ export function Header() {
<Link to="/" className="site-logo"> <Link to="/" className="site-logo">
<span className="site-logo-text">Sats Faucet</span> <span className="site-logo-text">Sats Faucet</span>
</Link> </Link>
<nav className="site-nav"> <nav className="site-nav" aria-label="Main navigation">
<Link <Link
to="/" to="/"
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"} className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}

View File

@@ -1,189 +0,0 @@
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

@@ -62,7 +62,7 @@ export function StatsSection({ refetchTrigger }: StatsSectionProps) {
const n = (v: number | undefined | null) => Number(v ?? 0); const n = (v: number | undefined | null) => Number(v ?? 0);
const ts = (v: number | undefined | null) => new Date(Number(v ?? 0) * 1000).toLocaleString(); const ts = (v: number | undefined | null) => new Date(Number(v ?? 0) * 1000).toLocaleString();
const dailyBudget = Number(stats.dailyBudgetSats) || 1; const dailyBudget = Number(stats.dailyBudgetSats) || 1;
const budgetUsed = 0; /* API does not expose "spent today" in sats */ const budgetUsed = n(stats.spentTodaySats);
const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100); const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100);
return ( return (
@@ -75,10 +75,10 @@ export function StatsSection({ refetchTrigger }: StatsSectionProps) {
<div className="stats-progress-wrap"> <div className="stats-progress-wrap">
<div className="stats-progress-label"> <div className="stats-progress-label">
Daily budget: <AnimatedNumber value={n(stats.dailyBudgetSats)} /> sats Daily budget: <AnimatedNumber value={budgetUsed} /> / <AnimatedNumber value={n(stats.dailyBudgetSats)} /> sats
</div> </div>
<div className="stats-progress-bar"> <div className="stats-progress-bar">
<div className="stats-progress-fill" style={{ width: `${100 - budgetPct}%` }} /> <div className="stats-progress-fill" style={{ width: `${budgetPct}%` }} />
</div> </div>
</div> </div>

View File

@@ -1,3 +1,5 @@
import { useEffect, useRef } from "react";
import confetti from "canvas-confetti";
import { Countdown } from "./Countdown"; import { Countdown } from "./Countdown";
import { useToast } from "../contexts/ToastContext"; import { useToast } from "../contexts/ToastContext";
import type { ConfirmResult } from "../api"; import type { ConfirmResult } from "../api";
@@ -19,6 +21,19 @@ function CheckIcon() {
export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps) { export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps) {
const { showToast } = useToast(); const { showToast } = useToast();
const amount = result.payout_sats ?? 0; const amount = result.payout_sats ?? 0;
const confettiFired = useRef(false);
useEffect(() => {
if (confettiFired.current) return;
confettiFired.current = true;
confetti({
particleCount: 80,
spread: 70,
origin: { y: 0.6 },
colors: ["#f97316", "#facc15", "#4ade80", "#38bdf8"],
disableForReducedMotion: true,
});
}, []);
const copyPaymentHash = () => { const copyPaymentHash = () => {
const hash = result.payment_hash; const hash = result.payment_hash;
@@ -26,19 +41,24 @@ export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps)
navigator.clipboard.writeText(hash).then(() => showToast("Copied")); navigator.clipboard.writeText(hash).then(() => showToast("Copied"));
}; };
const shareMessage = () => {
const text = `I just received ${amount} sats from the Sats Faucet! ⚡\nhttps://faucet.lnpulse.app`;
navigator.clipboard.writeText(text).then(() => showToast("Copied to clipboard"));
};
return ( return (
<div className="claim-wizard-step claim-wizard-step-success"> <div className="claim-wizard-step claim-wizard-step-success">
<div className="claim-wizard-success-icon" aria-hidden> <div className="claim-wizard-success-icon" aria-hidden>
<CheckIcon /> <CheckIcon />
</div> </div>
<h3 className="claim-wizard-step-title">Sats sent</h3> <h3 className="claim-wizard-step-title">Sats sent!</h3>
<p className="claim-wizard-success-amount"> <p className="claim-wizard-success-amount">
<span className="claim-wizard-success-amount-value">{amount}</span> <span className="claim-wizard-success-amount-value">{amount}</span>
<span className="claim-wizard-success-amount-unit"> sats</span> <span className="claim-wizard-success-amount-unit"> sats</span>
</p> </p>
{result.payment_hash && ( {result.payment_hash && (
<div className="claim-wizard-success-payment-hash"> <div className="claim-wizard-success-payment-hash">
<code className="claim-wizard-payment-hash-code">{result.payment_hash.slice(0, 16)}</code> <code className="claim-wizard-payment-hash-code">{result.payment_hash.slice(0, 16)}&hellip;</code>
<button type="button" className="btn-secondary claim-wizard-copy-btn" onClick={copyPaymentHash}> <button type="button" className="btn-secondary claim-wizard-copy-btn" onClick={copyPaymentHash}>
Copy hash Copy hash
</button> </button>
@@ -53,6 +73,9 @@ export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps)
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onDone}> <button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onDone}>
Done Done
</button> </button>
<button type="button" className="btn-secondary" onClick={shareMessage}>
Share
</button>
<button type="button" className="btn-secondary" onClick={onClaimAgain}> <button type="button" className="btn-secondary" onClick={onClaimAgain}>
Claim again later Claim again later
</button> </button>

View File

@@ -25,6 +25,11 @@ export function TransactionsPage() {
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => {
document.title = "Transactions — Sats Faucet";
return () => { document.title = "Sats Faucet — Free Bitcoin for Nostr Users"; };
}, []);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>("all"); const [directionFilter, setDirectionFilter] = useState<DirectionFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all"); const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sortOrder, setSortOrder] = useState<SortOrder>("newest"); const [sortOrder, setSortOrder] = useState<SortOrder>("newest");

View File

@@ -2410,6 +2410,109 @@ h1 {
.login-method-btn { .login-method-btn {
min-height: 48px; min-height: 48px;
} }
.claim-wizard-profile-card {
flex-wrap: wrap;
gap: 8px;
padding: 10px 12px;
}
.claim-wizard-profile-card-address {
font-size: 13px;
}
.claim-wizard-profile-card-edit {
width: 100%;
text-align: center;
}
.claim-wizard-rules {
padding: 12px;
}
.claim-wizard-rules-list {
font-size: 13px;
}
.claim-wizard-step-actions--row {
flex-direction: column;
}
.claim-wizard-step-actions--row button {
width: 100%;
}
.claim-wizard-quote-amount-value {
font-size: 2.5rem;
}
}
/* Rules summary in ConnectStep */
.claim-wizard-rules {
margin-top: var(--space-sm);
padding: var(--space-sm);
background: rgba(249, 115, 22, 0.06);
border: 1px solid rgba(249, 115, 22, 0.15);
border-radius: var(--radius-card);
}
.claim-wizard-rules-title {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.claim-wizard-rules-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
color: var(--text-muted);
}
.claim-wizard-rules-list strong {
color: var(--text);
}
/* Profile card in EligibilityStep */
.claim-wizard-profile-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card-hover);
border: 1px solid var(--border);
border-radius: var(--radius-card);
margin-bottom: var(--space-sm);
}
.claim-wizard-profile-card-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.claim-wizard-profile-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.claim-wizard-profile-card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-soft);
}
.claim-wizard-profile-card-address {
font-size: 15px;
font-weight: 500;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.claim-wizard-profile-card-edit {
flex-shrink: 0;
font-size: 13px;
padding: 6px 12px;
} }
/* Touch-friendly: ensure minimum tap targets */ /* Touch-friendly: ensure minimum tap targets */
@@ -2431,3 +2534,22 @@ h1 {
margin-bottom: 4px; margin-bottom: 4px;
} }
} }
/* Very small screens (320px) */
@media (max-width: 360px) {
.container {
padding: 8px 8px 16px;
}
.step-indicator-label {
font-size: 10px;
}
.claim-wizard-step-title {
font-size: 1rem;
}
.claim-wizard-step-desc {
font-size: 13px;
}
.site-logo-text {
font-size: 1rem;
}
}

View File

@@ -3,6 +3,18 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ["react", "react-dom", "react-router-dom"],
nostr: ["nostr-tools"],
ui: ["framer-motion", "qrcode", "canvas-confetti"],
},
},
},
},
preview: { preview: {
allowedHosts: ["faucet.lnpulse.app"], allowedHosts: ["faucet.lnpulse.app"],
}, },