diff --git a/backend/src/db/schema-sqlite.sql b/backend/src/db/schema-sqlite.sql
index 4181a99..5dabe93 100644
--- a/backend/src/db/schema-sqlite.sql
+++ b/backend/src/db/schema-sqlite.sql
@@ -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_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_status ON quotes(status);
diff --git a/backend/src/db/schema.pg.sql b/backend/src/db/schema.pg.sql
index fc9e35a..e6b5d4c 100644
--- a/backend/src/db/schema.pg.sql
+++ b/backend/src/db/schema.pg.sql
@@ -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_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_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql
index d244c3b..d9467db 100644
--- a/backend/src/db/schema.sql
+++ b/backend/src/db/schema.sql
@@ -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_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_status ON quotes(status);
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 02f1040..d2f6e27 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -9,6 +9,8 @@ import authRoutes from "./routes/auth.js";
import claimRoutes from "./routes/claim.js";
import userRoutes from "./routes/user.js";
+const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
+
async function main() {
const db = getDb();
await db.runMigrations();
@@ -17,6 +19,14 @@ async function main() {
const app = express();
if (config.trustProxy) app.set("trust proxy", 1);
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(
cors({
origin: (origin, cb) => {
@@ -50,12 +60,34 @@ async function main() {
userRoutes
);
- app.listen(config.port, () => {
+ const server = app.listen(config.port, () => {
console.log(`Faucet API listening on port ${config.port}`);
if (config.lnbitsBaseUrl && config.lnbitsAdminKey) {
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) => {
diff --git a/backend/src/middleware/nip98.ts b/backend/src/middleware/nip98.ts
index 8e79583..864735b 100644
--- a/backend/src/middleware/nip98.ts
+++ b/backend/src/middleware/nip98.ts
@@ -83,8 +83,8 @@ export async function nip98Auth(req: Request, res: Response, next: NextFunction)
}
// Reconstruct absolute URL (protocol + host + path + query)
- const proto = req.headers["x-forwarded-proto"] ?? (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http";
- const host = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
+ 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"] as string | undefined) ?? req.headers.host ?? "";
const path = req.originalUrl ?? req.url;
const absoluteUrl = `${proto}://${host}${path}`;
if (u !== absoluteUrl) {
diff --git a/backend/src/routes/claim.ts b/backend/src/routes/claim.ts
index afbbed5..807d8c8 100644
--- a/backend/src/routes/claim.ts
+++ b/backend/src/routes/claim.ts
@@ -140,6 +140,7 @@ router.post("/confirm", authOrNip98, async (req: Request, res: Response) => {
res.json({
success: true,
payout_sats: quote.payout_sats,
+ payment_hash: paymentHash,
next_eligible_at: cooldownEnd,
});
} catch (err) {
diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts
index 2a3a700..e8d910b 100644
--- a/backend/src/routes/public.ts
+++ b/backend/src/routes/public.ts
@@ -24,11 +24,14 @@ router.get("/config", (_req: Request, res: Response) => {
router.get("/stats", async (_req: Request, res: Response) => {
try {
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),
db.getTotalPaidSats(),
db.getTotalClaimsCount(),
- db.getClaimsCountSince(Math.floor(Date.now() / 1000) - 86400),
+ db.getClaimsCountSince(now - 86400),
+ db.getPaidSatsSince(dayStart),
db.getRecentPayouts(20),
db.getRecentDeposits(20),
]);
@@ -38,6 +41,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
totalClaims,
claimsLast24h: claims24h,
dailyBudgetSats: config.dailyBudgetSats,
+ spentTodaySats: spentToday,
recentPayouts: recent,
recentDeposits,
});
diff --git a/backend/src/services/eligibility.ts b/backend/src/services/eligibility.ts
index 2c939db..1bb6b6d 100644
--- a/backend/src/services/eligibility.ts
+++ b/backend/src/services/eligibility.ts
@@ -54,7 +54,7 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise<
};
}
- if (balanceSats < config.faucetMinSats) {
+ if (balanceSats < config.minWalletBalanceSats) {
return {
eligible: false,
denialCode: "insufficient_balance",
diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts
index 4c399d8..7a99c7a 100644
--- a/backend/src/services/nostr.ts
+++ b/backend/src/services/nostr.ts
@@ -101,7 +101,8 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
if (hasMetadata) score += 10;
if (notesInLookback >= config.minNotesCount) score += 20;
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 name: string | null = null;
diff --git a/deploy/faucet.lnpulse.app.conf b/deploy/faucet.lnpulse.app.conf
index 69fefdc..9bea375 100644
--- a/deploy/faucet.lnpulse.app.conf
+++ b/deploy/faucet.lnpulse.app.conf
@@ -1,35 +1,67 @@
server {
server_name faucet.lnpulse.app;
- # No root; all locations are proxied.
- # Increase body size if needed
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/ {
proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1;
-
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
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
- location /health {
+ location = /health {
proxy_pass http://127.0.0.1:3001/health;
proxy_set_header Host $host;
}
- # Frontend (Vite preview server)
- location / {
- proxy_pass http://127.0.0.1:5173;
- proxy_http_version 1.1;
+ # Static assets with hashed filenames — long-term cache
+ location /assets/ {
+ alias /var/www/faucet.lnpulse.app/dist/assets/;
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ access_log off;
+ }
- proxy_set_header Host $host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
+ # Frontend — serve static files with SPA fallback
+ location / {
+ 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
@@ -37,18 +69,14 @@ server {
ssl_certificate_key /etc/letsencrypt/live/faucet.lnpulse.app/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
-
}
+
server {
if ($host = faucet.lnpulse.app) {
return 301 https://$host$request_uri;
} # managed by Certbot
-
listen 80;
server_name faucet.lnpulse.app;
return 404; # managed by Certbot
-
-
}
-
diff --git a/frontend/index.html b/frontend/index.html
index 8a77665..178d814 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,49 @@
- Sats Faucet
+ Sats Faucet — Free Bitcoin for Nostr Users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt
new file mode 100644
index 0000000..3ffde25
--- /dev/null
+++ b/frontend/public/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Allow: /
+Sitemap: https://faucet.lnpulse.app/sitemap.xml
diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml
new file mode 100644
index 0000000..667ffb9
--- /dev/null
+++ b/frontend/public/sitemap.xml
@@ -0,0 +1,13 @@
+
+
+
+ https://faucet.lnpulse.app/
+ daily
+ 1.0
+
+
+ https://faucet.lnpulse.app/transactions
+ hourly
+ 0.7
+
+
diff --git a/frontend/src/ErrorBoundary.tsx b/frontend/src/ErrorBoundary.tsx
index c52c9ed..6ea0efe 100644
--- a/frontend/src/ErrorBoundary.tsx
+++ b/frontend/src/ErrorBoundary.tsx
@@ -23,18 +23,62 @@ export class ErrorBoundary extends Component {
render() {
if (this.state.hasError && this.state.error) {
return (
-
-
Something went wrong
-
- {this.state.error.message}
-
-
this.setState({ hasError: false, error: null })}
- style={{ marginTop: "1rem", padding: "8px 16px", cursor: "pointer" }}
- >
- Try again
-
+
+
⚡
+
+ Something went wrong
+
+
+ The app encountered an unexpected error. This has been logged.
+ You can try reloading the page.
+
+
+ window.location.reload()}
+ style={{
+ padding: "10px 24px",
+ background: "#f97316",
+ color: "#fff",
+ border: "none",
+ borderRadius: "8px",
+ fontSize: "14px",
+ fontWeight: 500,
+ cursor: "pointer",
+ }}
+ >
+ Reload page
+
+ 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
+
+
);
}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 62c6a42..851ce33 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -53,6 +53,7 @@ export interface Stats {
totalClaims: number;
claimsLast24h: number;
dailyBudgetSats: number;
+ spentTodaySats: number;
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
}
diff --git a/frontend/src/components/ClaimDenialCard.tsx b/frontend/src/components/ClaimDenialCard.tsx
deleted file mode 100644
index c5e5781..0000000
--- a/frontend/src/components/ClaimDenialCard.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { DenialState } from "../hooks/useClaimFlow";
-
-interface ClaimDenialCardProps {
- denial: DenialState;
- onDismiss?: () => void;
-}
-
-export function ClaimDenialCard({ denial, onDismiss }: ClaimDenialCardProps) {
- return (
-
-
{denial.message}
- {denial.next_eligible_at != null && (
-
- Next eligible: {new Date(denial.next_eligible_at * 1000).toLocaleString()}
-
- )}
- {onDismiss && (
-
- Dismiss
-
- )}
-
- );
-}
diff --git a/frontend/src/components/ClaimFlow.tsx b/frontend/src/components/ClaimFlow.tsx
deleted file mode 100644
index 294cb36..0000000
--- a/frontend/src/components/ClaimFlow.tsx
+++ /dev/null
@@ -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
(null);
- const [profile, setProfile] = React.useState(null);
- const [lightningAddress, setLightningAddress] = React.useState("");
- const [lightningAddressTouched, setLightningAddressTouched] = React.useState(false);
- const [showQuoteModal, setShowQuoteModal] = useState(false);
- const quoteModalDelayRef = useRef | 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 (
-
-
-
-
-
-
Get sats from the faucet
-
- 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.
-
-
-
onPubkeyChange(pk)}
- onDisconnect={handleDisconnect}
- />
-
- {pubkey && (
- <>
- {
- claim.cancelQuote();
- claim.clearDenial();
- }}
- />
-
-
-
- Your Lightning Address:
- 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}
- />
-
- {claim.loading === "quote"
- ? ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]
- : "Check eligibility"}
-
-
- {lightningAddressInvalid && (
-
- Enter a valid Lightning address (user@domain)
-
- )}
- {profile?.lightning_address && lightningAddress.trim() === profile.lightning_address.trim() && (
-
- ✓
- Filled from profile
-
- )}
-
-
- {claim.denial && (
-
- )}
- >
- )}
-
-
-
- {modalOpen && (
-
{
- if (claim.success) {
- handleDone();
- } else {
- claim.cancelQuote();
- claim.clearConfirmError();
- setShowQuoteModal(false);
- }
- }}
- title={modalTitle}
- preventClose={claim.loading === "confirm"}
- >
- {
- claim.cancelQuote();
- claim.clearConfirmError();
- setShowQuoteModal(false);
- }}
- onRetry={claim.confirmClaim}
- onDone={handleDone}
- />
-
- )}
-
- );
-}
diff --git a/frontend/src/components/ClaimModal.tsx b/frontend/src/components/ClaimModal.tsx
deleted file mode 100644
index 20b8557..0000000
--- a/frontend/src/components/ClaimModal.tsx
+++ /dev/null
@@ -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 (
-
-
-
- );
-}
-
-function SpinnerIcon() {
- return (
-
-
-
-
- );
-}
-
-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 (
-
-
- {phase === "quote" && quote && (
-
- Confirm payout
-
- {quote.payout_sats}
- sats
-
-
- To
- {lightningAddress}
-
-
-
- Expires in
-
-
-
- Send sats
-
-
- Cancel
-
-
-
- )}
-
- {phase === "sending" && (
-
-
- Sending sats via Lightning
-
- )}
-
- {phase === "success" && confirmResult && (
-
-
-
-
- Sent {confirmResult.payout_sats ?? 0} sats
- {confirmResult.payment_hash && (
-
- {
- setPaymentHashExpanded((e) => !e);
- }}
- aria-expanded={paymentHashExpanded}
- >
- {paymentHashExpanded
- ? confirmResult.payment_hash
- : `${confirmResult.payment_hash.slice(0, 12)}…`}
-
-
- Copy
-
-
- )}
- {confirmResult.next_eligible_at != null && (
-
- Next eligible:
-
- )}
-
-
- Done
-
-
- Share
-
-
-
- )}
-
- {phase === "failure" && confirmError && (
-
- {confirmError.message}
-
- {confirmError.allowRetry && !quoteExpired && (
-
- Try again
-
- )}
- {(!confirmError.allowRetry || quoteExpired) && (
-
- Re-check eligibility
-
- )}
-
- Close
-
-
-
- )}
-
-
- );
-}
diff --git a/frontend/src/components/ClaimQuoteModal.tsx b/frontend/src/components/ClaimQuoteModal.tsx
deleted file mode 100644
index ea54b8b..0000000
--- a/frontend/src/components/ClaimQuoteModal.tsx
+++ /dev/null
@@ -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 (
-
-
{confirmError.message}
-
- {confirmError.allowRetry && (
-
- {loading ? "Retrying…" : "Retry"}
-
- )}
-
- Close
-
-
-
- );
- }
-
- return (
-
-
- {quote.payout_sats}
- sats
-
-
- {expired ? (
- Quote expired
- ) : (
- <>Expires in {countdown}>
- )}
-
-
- To
- {lightningAddress}
-
-
-
- {loading ? "Sending…" : "Confirm"}
-
-
- Cancel
-
-
-
- );
-}
diff --git a/frontend/src/components/ClaimStepIndicator.tsx b/frontend/src/components/ClaimStepIndicator.tsx
deleted file mode 100644
index 31ad3c7..0000000
--- a/frontend/src/components/ClaimStepIndicator.tsx
+++ /dev/null
@@ -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 (
-
- {STEPS.map((step, index) => {
- const isActive = step.id === currentStep;
- const isPast = step.id < currentStep;
- return (
-
-
-
{step.label}
- {index < STEPS.length - 1 &&
}
-
- );
- })}
-
- );
-}
diff --git a/frontend/src/components/ClaimSuccessModal.tsx b/frontend/src/components/ClaimSuccessModal.tsx
deleted file mode 100644
index cf5c490..0000000
--- a/frontend/src/components/ClaimSuccessModal.tsx
+++ /dev/null
@@ -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 (
-
-
- {amount}
- sats sent
-
- {nextAt != null && (
-
- Next claim after {new Date(nextAt * 1000).toLocaleString()}
-
- )}
-
- Done
-
-
- );
-}
diff --git a/frontend/src/components/ConnectStep.tsx b/frontend/src/components/ConnectStep.tsx
index 2ec5fd5..486cbdc 100644
--- a/frontend/src/components/ConnectStep.tsx
+++ b/frontend/src/components/ConnectStep.tsx
@@ -1,4 +1,6 @@
+import { useEffect, useState } from "react";
import { ConnectNostr } from "./ConnectNostr";
+import { getConfig, type FaucetConfig } from "../api";
interface ConnectStepProps {
pubkey: string | null;
@@ -8,6 +10,12 @@ interface ConnectStepProps {
}
export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: ConnectStepProps) {
+ const [config, setConfig] = useState(null);
+
+ useEffect(() => {
+ getConfig().then(setConfig).catch(() => {});
+ }, []);
+
return (
Connect your Nostr account
@@ -22,6 +30,17 @@ export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: Co
onDisconnect={onDisconnect}
/>
+ {config && (
+
+
Faucet rules
+
+ Payout: {config.faucetMinSats}–{config.faucetMaxSats} sats (random)
+ Cooldown: {config.cooldownDays} day{config.cooldownDays !== 1 ? "s" : ""} between claims
+ Account age: at least {config.minAccountAgeDays} days
+ Activity score: minimum {config.minActivityScore}
+
+
+ )}
);
}
diff --git a/frontend/src/components/CountUpNumber.tsx b/frontend/src/components/CountUpNumber.tsx
deleted file mode 100644
index a08a3cd..0000000
--- a/frontend/src/components/CountUpNumber.tsx
+++ /dev/null
@@ -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(null);
- const rafId = useRef(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 {display} ;
-}
diff --git a/frontend/src/components/EligibilityStep.tsx b/frontend/src/components/EligibilityStep.tsx
index 1691833..dc91d91 100644
--- a/frontend/src/components/EligibilityStep.tsx
+++ b/frontend/src/components/EligibilityStep.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import { ClaimDenialPanel } from "./ClaimDenialPanel";
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
import type { DenialState } from "../hooks/useClaimFlow";
@@ -33,35 +34,48 @@ export function EligibilityStep({
onClearDenial,
onCheckAgain,
}: EligibilityStepProps) {
+ const [editing, setEditing] = useState(false);
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
+ const showProfileCard = fromProfile && !editing;
return (
Check eligibility
- Enter your Lightning address. We’ll verify cooldown and calculate your payout.
+ Enter your Lightning address. We'll verify cooldown and calculate your payout.
-
- Lightning address
- 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 && (
-
- From profile
-
- )}
-
+ {showProfileCard ? (
+
+
⚡
+
+ Lightning address from profile
+ {lightningAddress}
+
+
setEditing(true)}
+ >
+ Edit
+
+
+ ) : (
+
+ Lightning address
+ onLightningAddressChange(e.target.value)}
+ onBlur={() => setLightningAddressTouched(true)}
+ placeholder="you@wallet.com"
+ disabled={loading}
+ aria-invalid={invalid || undefined}
+ aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
+ />
+
+ )}
{invalid && (
Enter a valid Lightning address (user@domain)
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index e2077a1..20913ba 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -6,7 +6,7 @@ export function Footer() {
return (
-
+
Home
Transactions
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 8403716..37f301c 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -9,7 +9,7 @@ export function Header() {
Sats Faucet
-
+
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 (
-
- Quote expired
-
- ↻
- Re-check
-
-
- );
- }
-
- if (hasQuote && slotPhase !== "idle") {
- return (
-
- );
- }
-
- return (
-
- Potential range
-
- {minSats}–{maxSats}
- sats
-
-
- {tiers.map((i) => (
-
- ))}
-
-
- );
-}
-
-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 (
-
-
-
- {slotPhase === "locked" ? (
-
- ) : (
- slotValue
- )}
-
- sats
-
-
- Locked for
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/StatsSection.tsx b/frontend/src/components/StatsSection.tsx
index adc3805..8603a98 100644
--- a/frontend/src/components/StatsSection.tsx
+++ b/frontend/src/components/StatsSection.tsx
@@ -62,7 +62,7 @@ export function StatsSection({ refetchTrigger }: StatsSectionProps) {
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 budgetUsed = n(stats.spentTodaySats);
const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100);
return (
@@ -75,10 +75,10 @@ export function StatsSection({ refetchTrigger }: StatsSectionProps) {
- Daily budget:
sats
+ Daily budget:
/
sats
diff --git a/frontend/src/components/SuccessStep.tsx b/frontend/src/components/SuccessStep.tsx
index 541aa70..27bd44d 100644
--- a/frontend/src/components/SuccessStep.tsx
+++ b/frontend/src/components/SuccessStep.tsx
@@ -1,3 +1,5 @@
+import { useEffect, useRef } from "react";
+import confetti from "canvas-confetti";
import { Countdown } from "./Countdown";
import { useToast } from "../contexts/ToastContext";
import type { ConfirmResult } from "../api";
@@ -19,6 +21,19 @@ function CheckIcon() {
export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps) {
const { showToast } = useToast();
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 hash = result.payment_hash;
@@ -26,19 +41,24 @@ export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps)
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 (
-
Sats sent
+
Sats sent!
{amount}
sats
{result.payment_hash && (
- {result.payment_hash.slice(0, 16)}…
+ {result.payment_hash.slice(0, 16)}…
Copy hash
@@ -53,6 +73,9 @@ export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps)
Done
+
+ Share
+
Claim again later
diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx
index 3ab4bfd..31c88f1 100644
--- a/frontend/src/pages/TransactionsPage.tsx
+++ b/frontend/src/pages/TransactionsPage.tsx
@@ -25,6 +25,11 @@ export function TransactionsPage() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+
+ useEffect(() => {
+ document.title = "Transactions — Sats Faucet";
+ return () => { document.title = "Sats Faucet — Free Bitcoin for Nostr Users"; };
+ }, []);
const [directionFilter, setDirectionFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const [sortOrder, setSortOrder] = useState("newest");
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 6415033..bceb98e 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -2410,6 +2410,109 @@ h1 {
.login-method-btn {
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 */
@@ -2431,3 +2534,22 @@ h1 {
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;
+ }
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index e131410..29a07cb 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,6 +3,18 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
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: {
allowedHosts: ["faucet.lnpulse.app"],
},