first commit
Made-with: Cursor
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user