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}
-          
- +
+
+

+ Something went wrong +

+

+ The app encountered an unexpected error. This has been logged. + You can try reloading the page. +

+
+ + +
); } 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 && ( - - )} -
- ); -} 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(); - }} - /> - -
-
- - 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} - /> - -
- {lightningAddressInvalid && ( - - )} - {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 -
-
- - -
-
- )} - - {phase === "sending" && ( - - -

Sending sats via Lightning

-
- )} - - {phase === "success" && confirmResult && ( - -
- -
-

Sent {confirmResult.payout_sats ?? 0} sats

- {confirmResult.payment_hash && ( -
- - -
- )} - {confirmResult.next_eligible_at != null && ( -

- Next eligible: -

- )} -
- - -
-
- )} - - {phase === "failure" && confirmError && ( - -

{confirmError.message}

-
- {confirmError.allowRetry && !quoteExpired && ( - - )} - {(!confirmError.allowRetry || quoteExpired) && ( - - )} - -
-
- )} -
-
- ); -} 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 && ( - - )} - -
-
- ); - } - - return ( -
-
- {quote.payout_sats} - sats -
-
- {expired ? ( - Quote expired - ) : ( - <>Expires in {countdown} - )} -
-
- To - {lightningAddress} -
-
- - -
-
- ); -} 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()} -

- )} - -
- ); -} 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.

-
- - 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} +
+ +
+ ) : ( +
+ + 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 && (