196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|