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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: https://faucet.lnpulse.app/sitemap.xml
|
||||||
13
frontend/public/sitemap.xml
Normal file
13
frontend/public/sitemap.xml
Normal 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>
|
||||||
@@ -23,19 +23,63 @@ 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",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
background: "#0c1222",
|
||||||
|
color: "#f1f5f9",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>⚡</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => this.setState({ hasError: false, error: null })}
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
style={{ marginTop: "1rem", padding: "8px 16px", cursor: "pointer" }}
|
style={{
|
||||||
|
padding: "10px 24px",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#94a3b8",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
|
|||||||
@@ -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 }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}–{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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,15 +34,33 @@ 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. We’ll verify cooldown and calculate your payout.
|
Enter your Lightning address. We'll verify cooldown and calculate your payout.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{showProfileCard ? (
|
||||||
|
<div className="claim-wizard-profile-card">
|
||||||
|
<div className="claim-wizard-profile-card-icon" aria-hidden>⚡</div>
|
||||||
|
<div className="claim-wizard-profile-card-body">
|
||||||
|
<span className="claim-wizard-profile-card-label">Lightning address from profile</span>
|
||||||
|
<span className="claim-wizard-profile-card-address">{lightningAddress}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary claim-wizard-profile-card-edit"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="claim-wizard-address-row">
|
<div className="claim-wizard-address-row">
|
||||||
<label htmlFor="wizard-lightning-address">Lightning address</label>
|
<label htmlFor="wizard-lightning-address">Lightning address</label>
|
||||||
<input
|
<input
|
||||||
@@ -52,16 +71,11 @@ export function EligibilityStep({
|
|||||||
onBlur={() => setLightningAddressTouched(true)}
|
onBlur={() => setLightningAddressTouched(true)}
|
||||||
placeholder="you@wallet.com"
|
placeholder="you@wallet.com"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
readOnly={fromProfile}
|
|
||||||
aria-invalid={invalid || undefined}
|
aria-invalid={invalid || undefined}
|
||||||
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
|
aria-describedby={invalid ? "wizard-lightning-hint" : undefined}
|
||||||
/>
|
/>
|
||||||
{fromProfile && (
|
|
||||||
<span className="claim-wizard-profile-badge" title="From your Nostr profile">
|
|
||||||
From profile
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)}…</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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user