first commit
Made-with: Cursor
This commit is contained in:
24
frontend/src/components/ClaimDenialCard.tsx
Normal file
24
frontend/src/components/ClaimDenialCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/ClaimDenialPanel.tsx
Normal file
75
frontend/src/components/ClaimDenialPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Countdown } from "./Countdown";
|
||||
import type { DenialState } from "../hooks/useClaimFlow";
|
||||
|
||||
const DENIAL_CODE_EXPLANATIONS: Record<string, string> = {
|
||||
cooldown_pubkey: "You've already claimed recently. Each pubkey has a cooldown period.",
|
||||
cooldown_ip: "This IP has reached the claim limit for the cooldown period.",
|
||||
account_too_new: "Your Nostr account is too new. The faucet requires a minimum account age.",
|
||||
low_activity: "Your Nostr profile doesn't meet the minimum activity score (notes, following).",
|
||||
invalid_nip98: "Nostr signature verification failed.",
|
||||
invalid_lightning_address: "The Lightning address format is invalid or could not be resolved.",
|
||||
quote_expired: "The quote expired before confirmation.",
|
||||
payout_failed: "The Lightning payment failed. You can try again.",
|
||||
faucet_disabled: "The faucet is temporarily disabled.",
|
||||
emergency_stop: "The faucet is in emergency stop mode.",
|
||||
insufficient_balance: "The faucet pool has insufficient balance.",
|
||||
daily_budget_exceeded: "The daily payout budget has been reached.",
|
||||
};
|
||||
|
||||
interface ClaimDenialPanelProps {
|
||||
denial: DenialState;
|
||||
onDismiss?: () => void;
|
||||
/** When provided, shows a "Check again" button (e.g. in wizard step 2) */
|
||||
onCheckAgain?: () => void;
|
||||
}
|
||||
|
||||
export function ClaimDenialPanel({ denial, onDismiss, onCheckAgain }: ClaimDenialPanelProps) {
|
||||
const [whyExpanded, setWhyExpanded] = useState(false);
|
||||
const explanation = denial.code ? DENIAL_CODE_EXPLANATIONS[denial.code] ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="claim-denial-panel">
|
||||
<div className="claim-denial-panel-icon" aria-hidden>
|
||||
<span className="claim-denial-panel-icon-inner">⏳</span>
|
||||
</div>
|
||||
<h3 className="claim-denial-panel-title">Not eligible yet</h3>
|
||||
<p className="claim-denial-panel-message">{denial.message}</p>
|
||||
{denial.next_eligible_at != null && (
|
||||
<p className="claim-denial-panel-countdown">
|
||||
Next claim in: <Countdown targetUnixSeconds={denial.next_eligible_at} format="duration" />
|
||||
</p>
|
||||
)}
|
||||
{(denial.code || explanation) && (
|
||||
<div className="claim-denial-panel-why">
|
||||
<button
|
||||
type="button"
|
||||
className="claim-denial-panel-why-trigger"
|
||||
onClick={() => setWhyExpanded((e) => !e)}
|
||||
aria-expanded={whyExpanded}
|
||||
>
|
||||
Why?
|
||||
</button>
|
||||
{whyExpanded && (
|
||||
<div className="claim-denial-panel-why-content">
|
||||
{denial.code && <p className="claim-denial-panel-why-code">Code: {denial.code}</p>}
|
||||
{explanation && <p className="claim-denial-panel-why-text">{explanation}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="claim-denial-panel-actions">
|
||||
{onCheckAgain != null && (
|
||||
<button type="button" className="btn-primary claim-denial-panel-check-again" onClick={onCheckAgain}>
|
||||
Check again
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button type="button" className="btn-secondary claim-denial-panel-dismiss" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/ClaimFlow.tsx
Normal file
231
frontend/src/components/ClaimFlow.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
195
frontend/src/components/ClaimModal.tsx
Normal file
195
frontend/src/components/ClaimModal.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ClaimQuoteModal.tsx
Normal file
92
frontend/src/components/ClaimQuoteModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ClaimStepIndicator.tsx
Normal file
55
frontend/src/components/ClaimStepIndicator.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/ClaimSuccessModal.tsx
Normal file
42
frontend/src/components/ClaimSuccessModal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/ClaimWizard.tsx
Normal file
184
frontend/src/components/ClaimWizard.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { postUserRefreshProfile, type UserProfile } from "../api";
|
||||
import { useClaimFlow } from "../hooks/useClaimFlow";
|
||||
import { StepIndicator } from "./StepIndicator";
|
||||
import { ConnectStep } from "./ConnectStep";
|
||||
import { EligibilityStep } from "./EligibilityStep";
|
||||
import { ConfirmStep } from "./ConfirmStep";
|
||||
import { SuccessStep } from "./SuccessStep";
|
||||
|
||||
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
|
||||
|
||||
function isValidLightningAddress(addr: string): boolean {
|
||||
return LIGHTNING_ADDRESS_REGEX.test(addr.trim());
|
||||
}
|
||||
|
||||
function getWizardStep(
|
||||
hasPubkey: boolean,
|
||||
claimState: ReturnType<typeof useClaimFlow>["claimState"]
|
||||
): 1 | 2 | 3 | 4 {
|
||||
if (!hasPubkey) return 1;
|
||||
if (claimState === "success") return 4;
|
||||
if (claimState === "quote_ready" || claimState === "confirming" || claimState === "error") return 3;
|
||||
return 2;
|
||||
}
|
||||
|
||||
interface ClaimWizardProps {
|
||||
pubkey: string | null;
|
||||
onPubkeyChange: (pk: string | null) => void;
|
||||
onClaimSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ClaimWizard({ pubkey, onPubkeyChange, onClaimSuccess }: ClaimWizardProps) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [lightningAddress, setLightningAddress] = useState("");
|
||||
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
||||
const wizardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const claim = useClaimFlow();
|
||||
|
||||
const currentStep = useMemo(
|
||||
() => getWizardStep(!!pubkey, claim.claimState),
|
||||
[pubkey, claim.claimState]
|
||||
);
|
||||
|
||||
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 (currentStep === 2 && pubkey && wizardRef.current) {
|
||||
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [currentStep, pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 4 && wizardRef.current) {
|
||||
wizardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleDisconnect = () => {
|
||||
onPubkeyChange(null);
|
||||
setProfile(null);
|
||||
claim.cancelQuote();
|
||||
claim.resetSuccess();
|
||||
claim.clearDenial();
|
||||
claim.clearConfirmError();
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
claim.resetSuccess();
|
||||
onClaimSuccess?.();
|
||||
};
|
||||
|
||||
const handleClaimAgain = () => {
|
||||
claim.resetSuccess();
|
||||
claim.clearDenial();
|
||||
claim.clearConfirmError();
|
||||
setLightningAddressTouched(false);
|
||||
// Stay on step 2 (eligibility)
|
||||
};
|
||||
|
||||
const handleCheckEligibility = () => {
|
||||
claim.checkEligibility(lightningAddress);
|
||||
};
|
||||
|
||||
const handleCancelQuote = () => {
|
||||
claim.cancelQuote();
|
||||
claim.clearConfirmError();
|
||||
};
|
||||
|
||||
const lightningAddressInvalid =
|
||||
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
|
||||
const fromProfile =
|
||||
Boolean(profile?.lightning_address) &&
|
||||
lightningAddress.trim() === (profile?.lightning_address ?? "").trim();
|
||||
const quoteExpired =
|
||||
claim.quote != null && claim.quote.expires_at <= Math.floor(Date.now() / 1000);
|
||||
|
||||
return (
|
||||
<div className="content claim-wizard-content" ref={wizardRef}>
|
||||
<div className="ClaimWizard claim-wizard-root">
|
||||
<header className="claim-wizard-header">
|
||||
<div className="claim-wizard-header-row">
|
||||
<h2 className="claim-wizard-title">Get sats from the faucet</h2>
|
||||
{pubkey && (
|
||||
<button
|
||||
type="button"
|
||||
className="claim-wizard-disconnect"
|
||||
onClick={handleDisconnect}
|
||||
aria-label="Disconnect account"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StepIndicator currentStep={currentStep} />
|
||||
</header>
|
||||
|
||||
<div className="claim-wizard-body">
|
||||
{currentStep === 1 && (
|
||||
<ConnectStep
|
||||
pubkey={pubkey}
|
||||
displayName={profile?.name}
|
||||
onConnect={(pk) => onPubkeyChange(pk)}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<EligibilityStep
|
||||
lightningAddress={lightningAddress}
|
||||
onLightningAddressChange={setLightningAddress}
|
||||
lightningAddressTouched={lightningAddressTouched}
|
||||
setLightningAddressTouched={setLightningAddressTouched}
|
||||
invalid={lightningAddressInvalid}
|
||||
fromProfile={fromProfile}
|
||||
loading={claim.loading === "quote"}
|
||||
eligibilityProgressStep={claim.eligibilityProgressStep}
|
||||
denial={claim.denial}
|
||||
onCheckEligibility={handleCheckEligibility}
|
||||
onClearDenial={claim.clearDenial}
|
||||
onCheckAgain={() => {
|
||||
claim.clearDenial();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && claim.quote && (
|
||||
<ConfirmStep
|
||||
quote={claim.quote}
|
||||
lightningAddress={lightningAddress}
|
||||
quoteExpired={quoteExpired}
|
||||
confirming={claim.loading === "confirm"}
|
||||
confirmError={claim.confirmError}
|
||||
onConfirm={claim.confirmClaim}
|
||||
onCancel={handleCancelQuote}
|
||||
onRetry={claim.confirmClaim}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && claim.success && (
|
||||
<SuccessStep
|
||||
result={claim.success}
|
||||
onDone={handleDone}
|
||||
onClaimAgain={handleClaimAgain}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/ConfirmStep.tsx
Normal file
114
frontend/src/components/ConfirmStep.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Countdown } from "./Countdown";
|
||||
import type { QuoteResult } from "../api";
|
||||
import type { ConfirmErrorState } from "../hooks/useClaimFlow";
|
||||
|
||||
interface ConfirmStepProps {
|
||||
quote: QuoteResult | null;
|
||||
lightningAddress: string;
|
||||
quoteExpired: boolean;
|
||||
confirming: boolean;
|
||||
confirmError: ConfirmErrorState | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function SpinnerIcon() {
|
||||
return (
|
||||
<svg className="claim-wizard-spinner" width="32" height="32" 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 ConfirmStep({
|
||||
quote,
|
||||
lightningAddress,
|
||||
quoteExpired,
|
||||
confirming,
|
||||
confirmError,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: ConfirmStepProps) {
|
||||
if (!quote) return null;
|
||||
|
||||
return (
|
||||
<div className="claim-wizard-step claim-wizard-step-confirm">
|
||||
<AnimatePresence mode="wait">
|
||||
{confirming ? (
|
||||
<motion.div
|
||||
key="sending"
|
||||
className="claim-wizard-confirm-sending"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<SpinnerIcon />
|
||||
<p className="claim-wizard-confirm-sending-text">Sending sats via Lightning…</p>
|
||||
</motion.div>
|
||||
) : confirmError ? (
|
||||
<motion.div
|
||||
key="error"
|
||||
className="claim-wizard-confirm-error"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<h3 className="claim-wizard-step-title">Something went wrong</h3>
|
||||
<p className="claim-wizard-confirm-error-message">{confirmError.message}</p>
|
||||
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
|
||||
{confirmError.allowRetry && !quoteExpired && (
|
||||
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onRetry}>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
{confirmError.allowRetry && !quoteExpired ? "Cancel" : "Back"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="quote"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<h3 className="claim-wizard-step-title">Confirm payout</h3>
|
||||
<div className="claim-wizard-quote-amount">
|
||||
<span className="claim-wizard-quote-amount-value">{quote.payout_sats}</span>
|
||||
<span className="claim-wizard-quote-amount-unit">sats</span>
|
||||
</div>
|
||||
<div className="claim-wizard-quote-expiry">
|
||||
<Countdown targetUnixSeconds={quote.expires_at} format="clock" />
|
||||
<span className="claim-wizard-quote-expiry-label">Locked for</span>
|
||||
</div>
|
||||
<p className="claim-wizard-quote-destination">
|
||||
To <strong>{lightningAddress}</strong>
|
||||
</p>
|
||||
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary claim-wizard-btn-primary"
|
||||
onClick={onConfirm}
|
||||
disabled={quoteExpired}
|
||||
>
|
||||
Confirm payout
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/ConnectNostr.tsx
Normal file
70
frontend/src/components/ConnectNostr.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { clearToken } from "../api";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
|
||||
interface Props {
|
||||
pubkey: string | null;
|
||||
displayName?: string | null;
|
||||
onConnect: (pubkey: string) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
function getInitial(name: string | null | undefined): string {
|
||||
const n = (name ?? "").trim();
|
||||
if (n.length > 0) return n[0].toUpperCase();
|
||||
return "?";
|
||||
}
|
||||
|
||||
export function ConnectNostr({ pubkey, displayName, onConnect, onDisconnect }: Props) {
|
||||
const [modalOpen, setModalOpen] = React.useState(false);
|
||||
|
||||
const handleDisconnect = () => {
|
||||
clearToken();
|
||||
onDisconnect();
|
||||
};
|
||||
|
||||
if (pubkey) {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const shortNpub = npub.slice(0, 12) + "…";
|
||||
const display = displayName?.trim() || shortNpub;
|
||||
const initial = getInitial(displayName);
|
||||
|
||||
return (
|
||||
<div className="connect-pill-wrap">
|
||||
<div className="connect-pill">
|
||||
<span className="connect-pill-dot" aria-hidden />
|
||||
<span className="connect-pill-avatar" aria-hidden>
|
||||
{initial}
|
||||
</span>
|
||||
<span className="connect-pill-name">{display}</span>
|
||||
<span className="connect-pill-npub">{shortNpub}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="connect-pill-disconnect"
|
||||
onClick={handleDisconnect}
|
||||
aria-label="Disconnect"
|
||||
title="Disconnect"
|
||||
>
|
||||
<span className="connect-pill-disconnect-icon" aria-hidden>⊗</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="address-row">
|
||||
<button type="button" onClick={() => setModalOpen(true)}>
|
||||
Connect Nostr
|
||||
</button>
|
||||
</div>
|
||||
<LoginModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSuccess={onConnect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/ConnectStep.tsx
Normal file
28
frontend/src/components/ConnectStep.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { ConnectNostr } from "./ConnectNostr";
|
||||
|
||||
interface ConnectStepProps {
|
||||
pubkey: string | null;
|
||||
displayName?: string | null;
|
||||
onConnect: (pubkey: string) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export function ConnectStep({ pubkey, displayName, onConnect, onDisconnect }: ConnectStepProps) {
|
||||
return (
|
||||
<div className="claim-wizard-step claim-wizard-step-connect">
|
||||
<h3 className="claim-wizard-step-title">Connect your Nostr account</h3>
|
||||
<p className="claim-wizard-step-desc">
|
||||
Sign in with your Nostr key to prove your identity. Your Lightning address can be filled from your profile.
|
||||
</p>
|
||||
<div className="claim-wizard-connect-cta">
|
||||
<ConnectNostr
|
||||
pubkey={pubkey}
|
||||
displayName={displayName}
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/CountUpNumber.tsx
Normal file
34
frontend/src/components/CountUpNumber.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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>;
|
||||
}
|
||||
50
frontend/src/components/Countdown.tsx
Normal file
50
frontend/src/components/Countdown.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export type CountdownFormat = "clock" | "duration";
|
||||
|
||||
interface CountdownProps {
|
||||
targetUnixSeconds: number;
|
||||
format?: CountdownFormat;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatClock(secondsLeft: number): string {
|
||||
const m = Math.floor(secondsLeft / 60);
|
||||
const s = secondsLeft % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds into "Xd Xh Xm" for "next eligible" style countdown.
|
||||
* Exported for reuse in denial panel and success modal.
|
||||
*/
|
||||
export function formatDuration(secondsLeft: number): string {
|
||||
if (secondsLeft <= 0) return "0d 0h 0m";
|
||||
const d = Math.floor(secondsLeft / 86400);
|
||||
const h = Math.floor((secondsLeft % 86400) / 3600);
|
||||
const m = Math.floor((secondsLeft % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
parts.push(`${h}h`);
|
||||
parts.push(`${m}m`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function getSecondsLeft(targetUnixSeconds: number): number {
|
||||
return Math.max(0, targetUnixSeconds - Math.floor(Date.now() / 1000));
|
||||
}
|
||||
|
||||
export function Countdown({ targetUnixSeconds, format = "clock", className }: CountdownProps) {
|
||||
const [secondsLeft, setSecondsLeft] = useState(() => getSecondsLeft(targetUnixSeconds));
|
||||
|
||||
useEffect(() => {
|
||||
setSecondsLeft(getSecondsLeft(targetUnixSeconds));
|
||||
const t = setInterval(() => {
|
||||
setSecondsLeft(getSecondsLeft(targetUnixSeconds));
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [targetUnixSeconds]);
|
||||
|
||||
const text = format === "duration" ? formatDuration(secondsLeft) : formatClock(secondsLeft);
|
||||
return <span className={className}>{text}</span>;
|
||||
}
|
||||
86
frontend/src/components/DepositSection.tsx
Normal file
86
frontend/src/components/DepositSection.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { getDeposit, postRedeemCashu, type DepositInfo } from "../api";
|
||||
import { useToast } from "../contexts/ToastContext";
|
||||
|
||||
export function DepositSection() {
|
||||
const [deposit, setDeposit] = useState<DepositInfo | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [cashuToken, setCashuToken] = useState("");
|
||||
const [cashuLoading, setCashuLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
getDeposit().then((d) => {
|
||||
setDeposit(d);
|
||||
const qrContent = d.lightningAddress || d.lnurlp;
|
||||
if (qrContent) QRCode.toDataURL(qrContent, { width: 180 }).then(setQrDataUrl);
|
||||
}).catch(() => setDeposit(null));
|
||||
}, []);
|
||||
|
||||
const copyAddress = () => {
|
||||
if (!deposit?.lightningAddress) return;
|
||||
navigator.clipboard.writeText(deposit.lightningAddress);
|
||||
showToast("Copied");
|
||||
};
|
||||
|
||||
const handleRedeemCashu = async () => {
|
||||
const token = cashuToken.trim();
|
||||
if (!token || !token.toLowerCase().startsWith("cashu")) {
|
||||
showToast("Enter a valid Cashu token (cashuA... or cashuB...)");
|
||||
return;
|
||||
}
|
||||
setCashuLoading(true);
|
||||
try {
|
||||
const result = await postRedeemCashu(token);
|
||||
setCashuToken("");
|
||||
const amount = result.amount ?? result.netAmount ?? result.invoiceAmount;
|
||||
showToast(amount != null ? `Redeemed ${amount} sats to faucet!` : "Cashu token redeemed to faucet.");
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Redeem failed");
|
||||
} finally {
|
||||
setCashuLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!deposit) return null;
|
||||
if (!deposit.lightningAddress && !deposit.lnurlp) return null;
|
||||
|
||||
return (
|
||||
<div className="deposit-box">
|
||||
<h3>Fund the faucet</h3>
|
||||
{deposit.lightningAddress && (
|
||||
<div className="copy-row">
|
||||
<input type="text" readOnly value={deposit.lightningAddress} />
|
||||
<button type="button" onClick={copyAddress}>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{qrDataUrl && (
|
||||
<div className="qr-wrap">
|
||||
<img src={qrDataUrl} alt="Deposit QR" width={180} height={180} />
|
||||
</div>
|
||||
)}
|
||||
<div className="cashu-redeem">
|
||||
<label className="cashu-redeem-label">Redeem Cashu token to faucet</label>
|
||||
<textarea
|
||||
className="cashu-redeem-input"
|
||||
placeholder="Paste Cashu token (cashuA... or cashuB...)"
|
||||
value={cashuToken}
|
||||
onChange={(e) => setCashuToken(e.target.value)}
|
||||
rows={2}
|
||||
disabled={cashuLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="cashu-redeem-btn"
|
||||
onClick={handleRedeemCashu}
|
||||
disabled={cashuLoading || !cashuToken.trim()}
|
||||
>
|
||||
{cashuLoading ? "Redeeming…" : "Redeem to faucet"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/EligibilityStep.tsx
Normal file
103
frontend/src/components/EligibilityStep.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import { ClaimDenialPanel } from "./ClaimDenialPanel";
|
||||
import { ELIGIBILITY_PROGRESS_STEPS } from "../hooks/useClaimFlow";
|
||||
import type { DenialState } from "../hooks/useClaimFlow";
|
||||
|
||||
interface EligibilityStepProps {
|
||||
lightningAddress: string;
|
||||
onLightningAddressChange: (value: string) => void;
|
||||
lightningAddressTouched: boolean;
|
||||
setLightningAddressTouched: (t: boolean) => void;
|
||||
invalid: boolean;
|
||||
fromProfile: boolean;
|
||||
loading: boolean;
|
||||
eligibilityProgressStep: number | null;
|
||||
denial: DenialState | null;
|
||||
onCheckEligibility: () => void;
|
||||
onClearDenial: () => void;
|
||||
onCheckAgain?: () => void;
|
||||
}
|
||||
|
||||
const LIGHTNING_ADDRESS_REGEX = /^[^@]+@[^@]+$/;
|
||||
|
||||
export function EligibilityStep({
|
||||
lightningAddress,
|
||||
onLightningAddressChange,
|
||||
lightningAddressTouched,
|
||||
setLightningAddressTouched,
|
||||
invalid,
|
||||
fromProfile,
|
||||
loading,
|
||||
eligibilityProgressStep,
|
||||
denial,
|
||||
onCheckEligibility,
|
||||
onClearDenial,
|
||||
onCheckAgain,
|
||||
}: EligibilityStepProps) {
|
||||
const canCheck = !loading && lightningAddress.trim() !== "" && LIGHTNING_ADDRESS_REGEX.test(lightningAddress.trim());
|
||||
|
||||
return (
|
||||
<div className="claim-wizard-step claim-wizard-step-eligibility">
|
||||
<h3 className="claim-wizard-step-title">Check eligibility</h3>
|
||||
<p className="claim-wizard-step-desc">
|
||||
Enter your Lightning address. We’ll verify cooldown and calculate your payout.
|
||||
</p>
|
||||
|
||||
<div className="claim-wizard-address-row">
|
||||
<label htmlFor="wizard-lightning-address">Lightning address</label>
|
||||
<input
|
||||
id="wizard-lightning-address"
|
||||
type="text"
|
||||
value={lightningAddress}
|
||||
onChange={(e) => 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 && (
|
||||
<span className="claim-wizard-profile-badge" title="From your Nostr profile">
|
||||
From profile
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{invalid && (
|
||||
<p id="wizard-lightning-hint" className="claim-wizard-input-hint" role="alert">
|
||||
Enter a valid Lightning address (user@domain)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="claim-wizard-progress" role="status" aria-live="polite">
|
||||
<div className="claim-wizard-progress-bar" />
|
||||
<p className="claim-wizard-progress-text">
|
||||
{ELIGIBILITY_PROGRESS_STEPS[eligibilityProgressStep ?? 0]}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="claim-wizard-step-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary claim-wizard-btn-primary"
|
||||
onClick={onCheckEligibility}
|
||||
disabled={!canCheck}
|
||||
>
|
||||
Check eligibility
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{denial && (
|
||||
<div className="claim-wizard-denial-wrap">
|
||||
<ClaimDenialPanel
|
||||
denial={denial}
|
||||
onDismiss={onClearDenial}
|
||||
onCheckAgain={onCheckAgain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/Footer.tsx
Normal file
22
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer-inner">
|
||||
<nav className="site-footer-nav">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/transactions">Transactions</Link>
|
||||
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">
|
||||
Bitcoin.org
|
||||
</a>
|
||||
</nav>
|
||||
<p className="site-footer-copy">
|
||||
© {year} Sats Faucet. Fund the faucet to keep it running.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/Header.tsx
Normal file
29
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
export function Header() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="site-header-inner">
|
||||
<Link to="/" className="site-logo">
|
||||
<span className="site-logo-text">Sats Faucet</span>
|
||||
</Link>
|
||||
<nav className="site-nav">
|
||||
<Link
|
||||
to="/"
|
||||
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/transactions"
|
||||
className={location.pathname === "/transactions" ? "site-nav-link active" : "site-nav-link"}
|
||||
>
|
||||
Transactions
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
323
frontend/src/components/LoginModal.tsx
Normal file
323
frontend/src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { nip19, SimplePool, generateSecretKey, finalizeEvent } from "nostr-tools";
|
||||
import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
|
||||
import {
|
||||
postAuthLoginWithSigner,
|
||||
postAuthLogin,
|
||||
postUserRefreshProfile,
|
||||
setToken,
|
||||
hasNostr,
|
||||
type ApiError,
|
||||
} from "../api";
|
||||
import { Modal } from "./Modal";
|
||||
import { RemoteSignerQR } from "./RemoteSignerQR";
|
||||
import { isValidRemoteSignerInput } from "../utils/remoteSignerValidation";
|
||||
|
||||
const REMOTE_SIGNER_MODE_KEY = "nostr_remote_signer_mode";
|
||||
const MOBILE_BREAKPOINT = 640;
|
||||
|
||||
type Tab = "extension" | "remote" | "nsec";
|
||||
type RemoteSignerMode = "paste" | "scan";
|
||||
|
||||
function getDefaultRemoteSignerMode(): RemoteSignerMode {
|
||||
if (typeof window === "undefined") return "paste";
|
||||
const saved = localStorage.getItem(REMOTE_SIGNER_MODE_KEY);
|
||||
if (saved === "paste" || saved === "scan") return saved;
|
||||
return window.innerWidth <= MOBILE_BREAKPOINT ? "scan" : "paste";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (pubkey: string) => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("extension");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [remoteSignerMode, setRemoteSignerModeState] = useState<RemoteSignerMode>(getDefaultRemoteSignerMode);
|
||||
const setRemoteSignerMode = useCallback((mode: RemoteSignerMode) => {
|
||||
setRemoteSignerModeState(mode);
|
||||
localStorage.setItem(REMOTE_SIGNER_MODE_KEY, mode);
|
||||
}, []);
|
||||
|
||||
const [bunkerInput, setBunkerInput] = useState("");
|
||||
const [pasteInlineError, setPasteInlineError] = useState<string | null>(null);
|
||||
const [nsecNpubInput, setNsecNpubInput] = useState("");
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
setError(null);
|
||||
setPasteInlineError(null);
|
||||
setBunkerInput("");
|
||||
setNsecNpubInput("");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const runLogin = useCallback(
|
||||
async (sign: () => Promise<{ token: string; pubkey: string }>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPasteInlineError(null);
|
||||
try {
|
||||
const { token, pubkey } = await sign();
|
||||
setToken(token);
|
||||
// Trigger profile fetch so backend has token before ClaimFlow effect runs (helps remote signer login)
|
||||
postUserRefreshProfile().catch(() => {});
|
||||
onSuccess(pubkey);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[onSuccess]
|
||||
);
|
||||
const runLoginRef = useRef(runLogin);
|
||||
runLoginRef.current = runLogin;
|
||||
|
||||
const handleExtension = () => {
|
||||
if (!hasNostr()) {
|
||||
setError("Install a Nostr extension (e.g. nos2x, Alby) to use this option.");
|
||||
return;
|
||||
}
|
||||
runLogin(() => postAuthLogin());
|
||||
};
|
||||
|
||||
const handleRemoteSignerPaste = async () => {
|
||||
const raw = bunkerInput.trim();
|
||||
if (!raw) {
|
||||
setError("Enter a bunker URL (bunker://...) or NIP-05 (user@domain).");
|
||||
return;
|
||||
}
|
||||
if (!isValidRemoteSignerInput(raw)) {
|
||||
setPasteInlineError("Enter a valid bunker URL (bunker://...) or NIP-05 (name@domain.com).");
|
||||
return;
|
||||
}
|
||||
setPasteInlineError(null);
|
||||
const bp = await parseBunkerInput(raw);
|
||||
if (!bp || bp.relays.length === 0) {
|
||||
setError("Could not parse bunker URL or NIP-05. Need at least one relay.");
|
||||
return;
|
||||
}
|
||||
const pool = new SimplePool();
|
||||
const clientSecret = generateSecretKey();
|
||||
try {
|
||||
const signer = BunkerSigner.fromBunker(clientSecret, bp, { pool });
|
||||
await signer.connect();
|
||||
const sign = (e: { kind: number; tags: string[][]; content: string; created_at: number }) => signer.signEvent(e);
|
||||
await runLogin(() => postAuthLoginWithSigner(sign));
|
||||
} finally {
|
||||
pool.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoteSignerConnectViaQR = useCallback(
|
||||
async (sign: Parameters<typeof postAuthLoginWithSigner>[0]) => {
|
||||
await runLoginRef.current(() => postAuthLoginWithSigner(sign));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRemoteSignerQRError = useCallback((message: string | null) => {
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
const handlePasteBlur = () => {
|
||||
const raw = bunkerInput.trim();
|
||||
if (!raw) {
|
||||
setPasteInlineError(null);
|
||||
return;
|
||||
}
|
||||
setPasteInlineError(isValidRemoteSignerInput(raw) ? null : "Enter a valid bunker URL (bunker://...) or NIP-05 (name@domain.com).");
|
||||
};
|
||||
|
||||
const handleNsecNpub = async () => {
|
||||
const raw = nsecNpubInput.trim();
|
||||
if (!raw) {
|
||||
setError("Paste your nsec (secret key) or npub (public key).");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type === "npub") {
|
||||
setError("Npub (public key) cannot sign. Paste your nsec to log in and claim, or use Extension / Remote signer.");
|
||||
return;
|
||||
}
|
||||
if (decoded.type !== "nsec") {
|
||||
setError("Unsupported format. Use nsec or npub.");
|
||||
return;
|
||||
}
|
||||
const secretKey = decoded.data;
|
||||
const sign = (e: { kind: number; tags: string[][]; content: string; created_at: number }) =>
|
||||
Promise.resolve(finalizeEvent(e, secretKey));
|
||||
await runLogin(() => postAuthLoginWithSigner(sign));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid nsec/npub. Check the format.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading}>
|
||||
<div className="login-modal-tabs">
|
||||
{(["extension", "remote", "nsec"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`login-modal-tab ${tab === t ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setTab(t);
|
||||
setError(null);
|
||||
setPasteInlineError(null);
|
||||
}}
|
||||
>
|
||||
{t === "extension" && "Extension"}
|
||||
{t === "remote" && "Remote signer"}
|
||||
{t === "nsec" && "Nsec / Npub"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="login-modal-body">
|
||||
{tab === "extension" && (
|
||||
<div className="login-method">
|
||||
<p className="login-method-desc">Use a Nostr browser extension (NIP-07) such as nos2x or Alby.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="login-method-btn"
|
||||
onClick={handleExtension}
|
||||
disabled={loading || !hasNostr()}
|
||||
>
|
||||
{loading ? "Connecting…" : hasNostr() ? "Login with extension" : "Extension not detected"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "remote" && (
|
||||
<>
|
||||
<div className="login-segment" role="group" aria-label="Remote signer method">
|
||||
<button
|
||||
type="button"
|
||||
className="login-segment-option"
|
||||
aria-pressed={remoteSignerMode === "paste"}
|
||||
onClick={() => {
|
||||
setRemoteSignerMode("paste");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Paste link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-segment-option"
|
||||
aria-pressed={remoteSignerMode === "scan"}
|
||||
onClick={() => {
|
||||
setRemoteSignerMode("scan");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Scan QR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{remoteSignerMode === "paste" && (
|
||||
<div className="login-method">
|
||||
<h3 className="login-method-title">Connect a remote signer</h3>
|
||||
<p className="login-method-desc">
|
||||
Paste a bunker URL or NIP-05. Your signer stays in control.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className="login-modal-input"
|
||||
placeholder="bunker://... or name@domain.com"
|
||||
value={bunkerInput}
|
||||
onChange={(e) => {
|
||||
setBunkerInput(e.target.value);
|
||||
if (pasteInlineError) setPasteInlineError(null);
|
||||
}}
|
||||
onBlur={handlePasteBlur}
|
||||
disabled={loading}
|
||||
aria-invalid={!!pasteInlineError}
|
||||
aria-describedby={pasteInlineError ? "paste-inline-error" : undefined}
|
||||
/>
|
||||
{pasteInlineError && (
|
||||
<p id="paste-inline-error" className="login-modal-inline-error" role="alert">
|
||||
{pasteInlineError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="login-method-btn"
|
||||
onClick={handleRemoteSignerPaste}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-modal-secondary-link"
|
||||
onClick={() => setRemoteSignerMode("scan")}
|
||||
>
|
||||
Prefer scanning? Scan QR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteSignerMode === "scan" && (
|
||||
<div className="login-method">
|
||||
<h3 className="login-method-title">Scan with your signer</h3>
|
||||
<p className="login-method-desc">
|
||||
Open your signer app and scan to connect. Approve requests in the signer.
|
||||
</p>
|
||||
<RemoteSignerQR
|
||||
onConnect={handleRemoteSignerConnectViaQR}
|
||||
onError={handleRemoteSignerQRError}
|
||||
disabled={false}
|
||||
signingIn={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-modal-secondary-link"
|
||||
onClick={() => setRemoteSignerMode("paste")}
|
||||
>
|
||||
Prefer pasting? Paste link
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "nsec" && (
|
||||
<div className="login-method">
|
||||
<p className="login-method-desc">
|
||||
Paste your <strong>nsec</strong> to sign in (kept in this tab only). Npub alone cannot sign.
|
||||
</p>
|
||||
<textarea
|
||||
className="login-modal-textarea"
|
||||
placeholder="nsec1… or npub1…"
|
||||
value={nsecNpubInput}
|
||||
onChange={(e) => setNsecNpubInput(e.target.value)}
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-method-btn"
|
||||
onClick={handleNsecNpub}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Signing in…" : "Login with nsec"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="login-modal-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/Modal.tsx
Normal file
118
frontend/src/components/Modal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useCallback, useRef } from "react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
/** If true, do not close on overlay click (e.g. when loading). */
|
||||
preventClose?: boolean;
|
||||
}
|
||||
|
||||
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])";
|
||||
|
||||
function getFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => !el.hasAttribute("disabled") && el.offsetParent != null
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, preventClose }: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !preventClose) onClose();
|
||||
},
|
||||
[onClose, preventClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, handleEscape]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !modalRef.current) return;
|
||||
const focusables = getFocusables(modalRef.current);
|
||||
const first = focusables[0];
|
||||
if (first) first.focus();
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key !== "Tab" || !modalRef.current) return;
|
||||
const focusables = getFocusables(modalRef.current);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const target = e.target as HTMLElement;
|
||||
if (e.shiftKey) {
|
||||
if (target === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (target === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (!preventClose) onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const duration = reduceMotion ? 0 : 0.2;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? "modal-title" : undefined}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration }}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
className="modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration }}
|
||||
>
|
||||
{(title != null || !preventClose) && (
|
||||
<div className="modal-header">
|
||||
{title != null && (
|
||||
<h2 id="modal-title" className="modal-title">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{!preventClose && (
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-body">{children}</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/PayoutCard.tsx
Normal file
189
frontend/src/components/PayoutCard.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/RemoteSignerQR.tsx
Normal file
185
frontend/src/components/RemoteSignerQR.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { SimplePool, generateSecretKey, getPublicKey } from "nostr-tools";
|
||||
import { BunkerSigner, createNostrConnectURI } from "nostr-tools/nip46";
|
||||
import { useToast } from "../contexts/ToastContext";
|
||||
import type { Nip98Signer } from "../api";
|
||||
|
||||
const DEFAULT_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
const WAIT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
interface RemoteSignerQRProps {
|
||||
onConnect: (sign: Nip98Signer) => Promise<void>;
|
||||
onError: (message: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** When true, show "Signing in…" but keep mounted so the signer pool stays alive. */
|
||||
signingIn?: boolean;
|
||||
}
|
||||
|
||||
export function RemoteSignerQR({ onConnect, onError, disabled, signingIn }: RemoteSignerQRProps) {
|
||||
const { showToast } = useToast();
|
||||
const [connectionUri, setConnectionUri] = useState<string | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [copyLabel, setCopyLabel] = useState("Copy");
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const poolRef = useRef<SimplePool | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimeoutRef = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateUri = useCallback(() => {
|
||||
const clientSecret = generateSecretKey();
|
||||
const clientPubkey = getPublicKey(clientSecret);
|
||||
const secret = randomHex(32);
|
||||
const uri = createNostrConnectURI({
|
||||
clientPubkey,
|
||||
relays: DEFAULT_RELAYS,
|
||||
secret,
|
||||
name: "Sats Faucet",
|
||||
url: typeof window !== "undefined" ? window.location.origin : "",
|
||||
});
|
||||
return { uri, clientSecret };
|
||||
}, []);
|
||||
|
||||
const startWaiting = useCallback(
|
||||
(uri: string, clientSecret: Uint8Array) => {
|
||||
clearTimeoutRef();
|
||||
const pool = new SimplePool();
|
||||
poolRef.current = pool;
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
setWaiting(true);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
if (abortRef.current?.signal.aborted) return;
|
||||
abortRef.current?.abort();
|
||||
onError("Connection timed out. Try regenerating the QR code.");
|
||||
setWaiting(false);
|
||||
}, WAIT_TIMEOUT_MS);
|
||||
|
||||
BunkerSigner.fromURI(clientSecret, uri, { pool }, abort.signal)
|
||||
.then(async (signer) => {
|
||||
if (abortRef.current?.signal.aborted) return;
|
||||
clearTimeoutRef();
|
||||
setWaiting(false);
|
||||
const sign: Nip98Signer = (e) => signer.signEvent(e);
|
||||
try {
|
||||
await onConnect(sign);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
onError(msg);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (abortRef.current?.signal.aborted) return;
|
||||
clearTimeoutRef();
|
||||
setWaiting(false);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
onError(msg);
|
||||
});
|
||||
},
|
||||
[onConnect, onError, clearTimeoutRef]
|
||||
);
|
||||
|
||||
const regenerate = useCallback(() => {
|
||||
clearTimeoutRef();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
if (poolRef.current) {
|
||||
poolRef.current.destroy();
|
||||
poolRef.current = null;
|
||||
}
|
||||
onError(null);
|
||||
const { uri, clientSecret } = generateUri();
|
||||
setConnectionUri(uri);
|
||||
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
||||
startWaiting(uri, clientSecret);
|
||||
}, [generateUri, startWaiting, onError, clearTimeoutRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
const { uri, clientSecret } = generateUri();
|
||||
setConnectionUri(uri);
|
||||
setQrDataUrl(null);
|
||||
QRCode.toDataURL(uri, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
||||
startWaiting(uri, clientSecret);
|
||||
return () => {
|
||||
clearTimeoutRef();
|
||||
abortRef.current?.abort();
|
||||
if (poolRef.current) {
|
||||
poolRef.current.destroy();
|
||||
poolRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [disabled, generateUri, startWaiting, clearTimeoutRef]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!connectionUri) return;
|
||||
navigator.clipboard.writeText(connectionUri).then(() => {
|
||||
setCopyLabel("Copied");
|
||||
showToast("Copied");
|
||||
setTimeout(() => setCopyLabel("Copy"), 1500);
|
||||
});
|
||||
}, [connectionUri, showToast]);
|
||||
|
||||
if (disabled) return null;
|
||||
|
||||
return (
|
||||
<div className="remote-signer-qr-content">
|
||||
<div className="remote-signer-qr-card">
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="Scan to connect with your signer" />
|
||||
) : (
|
||||
<span className="remote-signer-qr-placeholder">Generating QR…</span>
|
||||
)}
|
||||
</div>
|
||||
{signingIn ? (
|
||||
<p className="remote-signer-waiting" role="status">
|
||||
Signing in…
|
||||
</p>
|
||||
) : waiting ? (
|
||||
<p className="remote-signer-waiting" role="status">
|
||||
Waiting for signer approval…
|
||||
</p>
|
||||
) : null}
|
||||
<div className="remote-signer-connection-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={connectionUri ?? ""}
|
||||
aria-label="Connection string"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="remote-signer-copy-btn"
|
||||
onClick={handleCopy}
|
||||
disabled={!connectionUri}
|
||||
aria-label="Copy connection string"
|
||||
>
|
||||
{copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="remote-signer-regenerate-btn" onClick={regenerate}>
|
||||
Regenerate QR
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/StatsSection.tsx
Normal file
123
frontend/src/components/StatsSection.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { getStats, type Stats } from "../api";
|
||||
|
||||
const REFRESH_MS = 45_000;
|
||||
|
||||
export interface StatsSectionProps {
|
||||
/** Optional refetch trigger: when this value changes, stats are refetched. */
|
||||
refetchTrigger?: number;
|
||||
}
|
||||
|
||||
function AnimatedNumber({ value }: { value: number }) {
|
||||
return (
|
||||
<motion.span
|
||||
key={value}
|
||||
initial={{ opacity: 0.6 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsSection({ refetchTrigger }: StatsSectionProps) {
|
||||
const [stats, setStats] = React.useState<Stats | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async (userRefresh = false) => {
|
||||
if (userRefresh) setRefreshing(true);
|
||||
try {
|
||||
const s = await getStats();
|
||||
setStats(s);
|
||||
} catch {
|
||||
setStats(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (userRefresh) setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, REFRESH_MS);
|
||||
return () => clearInterval(t);
|
||||
}, [load, refetchTrigger]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="stats-box stats-skeleton">
|
||||
<div className="skeleton-line balance" />
|
||||
<div className="skeleton-line short" />
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!stats) return <div className="stats-box"><p>Stats unavailable</p></div>;
|
||||
|
||||
const n = (v: number | undefined | null) => Number(v ?? 0);
|
||||
const ts = (v: number | undefined | null) => new Date(Number(v ?? 0) * 1000).toLocaleString();
|
||||
const dailyBudget = Number(stats.dailyBudgetSats) || 1;
|
||||
const budgetUsed = 0; /* API does not expose "spent today" in sats */
|
||||
const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100);
|
||||
|
||||
return (
|
||||
<div className="stats-box">
|
||||
<h3>Faucet stats</h3>
|
||||
<p className="stats-balance">
|
||||
<AnimatedNumber value={n(stats.balanceSats)} /> sats
|
||||
</p>
|
||||
<p className="stats-balance-label">Pool balance</p>
|
||||
|
||||
<div className="stats-progress-wrap">
|
||||
<div className="stats-progress-label">
|
||||
Daily budget: <AnimatedNumber value={n(stats.dailyBudgetSats)} /> sats
|
||||
</div>
|
||||
<div className="stats-progress-bar">
|
||||
<div className="stats-progress-fill" style={{ width: `${100 - budgetPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-rows">
|
||||
<div className="stats-row">
|
||||
<span>Total paid</span>
|
||||
<span><AnimatedNumber value={n(stats.totalPaidSats)} /> sats</span>
|
||||
</div>
|
||||
<div className="stats-row">
|
||||
<span>Total claims</span>
|
||||
<span><AnimatedNumber value={n(stats.totalClaims)} /></span>
|
||||
</div>
|
||||
<div className="stats-row">
|
||||
<span>Claims (24h)</span>
|
||||
<span><AnimatedNumber value={n(stats.claimsLast24h)} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.recentPayouts?.length > 0 && (
|
||||
<>
|
||||
<h3 className="stats-recent-title">Recent payouts</h3>
|
||||
<ul className="stats-recent-list">
|
||||
{stats.recentPayouts.slice(0, 10).map((p, i) => (
|
||||
<li key={i}>
|
||||
{p.pubkey_prefix ?? "…"} — {n(p.payout_sats).toLocaleString()} sats — {ts(p.claimed_at)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`entropy-btn stats-refresh ${refreshing ? "stats-refresh--spinning" : ""}`}
|
||||
onClick={() => load(true)}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<span className="stats-refresh-icon" aria-hidden>↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/StepIndicator.tsx
Normal file
47
frontend/src/components/StepIndicator.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
|
||||
const STEPS = [
|
||||
{ step: 1, label: "Connect" },
|
||||
{ step: 2, label: "Check" },
|
||||
{ step: 3, label: "Confirm" },
|
||||
{ step: 4, label: "Receive" },
|
||||
] as const;
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="step-indicator" role="progressbar" aria-valuenow={currentStep} aria-valuemin={1} aria-valuemax={4} aria-label={`Step ${currentStep} of 4`}>
|
||||
<ol className="step-indicator-list">
|
||||
{STEPS.map(({ step, label }, index) => {
|
||||
const isCompleted = step < currentStep;
|
||||
const isCurrent = step === currentStep;
|
||||
const isFuture = step > currentStep;
|
||||
return (
|
||||
<li
|
||||
key={step}
|
||||
className={`step-indicator-item ${isCurrent ? "step-indicator-item--current" : ""} ${isCompleted ? "step-indicator-item--completed" : ""} ${isFuture ? "step-indicator-item--future" : ""}`}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
>
|
||||
{index > 0 && <span className="step-indicator-connector" aria-hidden />}
|
||||
<span className="step-indicator-marker">
|
||||
{isCompleted ? <CheckIcon /> : <span className="step-indicator-number">{step}</span>}
|
||||
</span>
|
||||
<span className="step-indicator-label">{label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/SuccessStep.tsx
Normal file
63
frontend/src/components/SuccessStep.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Countdown } from "./Countdown";
|
||||
import { useToast } from "../contexts/ToastContext";
|
||||
import type { ConfirmResult } from "../api";
|
||||
|
||||
interface SuccessStepProps {
|
||||
result: ConfirmResult;
|
||||
onDone: () => void;
|
||||
onClaimAgain: () => 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SuccessStep({ result, onDone, onClaimAgain }: SuccessStepProps) {
|
||||
const { showToast } = useToast();
|
||||
const amount = result.payout_sats ?? 0;
|
||||
|
||||
const copyPaymentHash = () => {
|
||||
const hash = result.payment_hash;
|
||||
if (!hash) return;
|
||||
navigator.clipboard.writeText(hash).then(() => showToast("Copied"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="claim-wizard-step claim-wizard-step-success">
|
||||
<div className="claim-wizard-success-icon" aria-hidden>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<h3 className="claim-wizard-step-title">Sats sent</h3>
|
||||
<p className="claim-wizard-success-amount">
|
||||
<span className="claim-wizard-success-amount-value">{amount}</span>
|
||||
<span className="claim-wizard-success-amount-unit"> sats</span>
|
||||
</p>
|
||||
{result.payment_hash && (
|
||||
<div className="claim-wizard-success-payment-hash">
|
||||
<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}>
|
||||
Copy hash
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{result.next_eligible_at != null && (
|
||||
<p className="claim-wizard-success-next">
|
||||
Next eligible: <Countdown targetUnixSeconds={result.next_eligible_at} format="duration" />
|
||||
</p>
|
||||
)}
|
||||
<div className="claim-wizard-step-actions claim-wizard-step-actions--row">
|
||||
<button type="button" className="btn-primary claim-wizard-btn-primary" onClick={onDone}>
|
||||
Done
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onClaimAgain}>
|
||||
Claim again later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/Toast.tsx
Normal file
24
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export function Toast({ message, visible, onDismiss, durationMs = 2500 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
if (!visible || !message) return;
|
||||
const t = setTimeout(onDismiss, durationMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [visible, message, durationMs, onDismiss]);
|
||||
|
||||
if (!visible || !message) return null;
|
||||
|
||||
return (
|
||||
<div className="toast" role="status" aria-live="polite">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user