mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
feat: add production-ready payment UX improvements
Enhances the zap QR payment experience with comprehensive status indicators, user controls, and visual feedback for a top-tier UX. New Features: - ⏱️ Live countdown timer showing time until invoice expires - Yellow warning when < 5 minutes remaining - Red "Invoice expired" message when time is up - 🔄 Manual "Check Payment Now" button for immediate verification - Disabled during auto-checks and when expired - Shows spinner during check with feedback toast - 📊 Real-time status indicators: - Green dot + "Last checked Xs ago" timestamp - Spinner + "Checking..." during verification - Gray dot + "Offline - verification paused" when offline - ✨ Subtle pulse animation on QR border during checks - 🌐 Network offline detection pauses polling automatically - 🚫 Expired invoice handling: - Disables "Open in External Wallet" button - Stops all verification attempts - Clear visual indication with countdown Technical Improvements: - Network online/offline event listeners - 1-second countdown interval for smooth time display - Manual check with instant feedback (not just background) - Format helpers for MM:SS time and relative timestamps - Smart button variants (outline when verify available) User Benefits: - Always know invoice status at a glance - No surprises about expiration - Manual control when impatient - Clear feedback on network issues - Professional, informative payment flow
This commit is contained in:
@@ -133,7 +133,7 @@ export function ZapWindow({
|
|||||||
// Poll for payment completion via LNURL verify endpoint
|
// Poll for payment completion via LNURL verify endpoint
|
||||||
const PAYMENT_CHECK_INTERVAL = 30000; // Check every 30 seconds
|
const PAYMENT_CHECK_INTERVAL = 30000; // Check every 30 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!verifyUrl || !showQrDialog || isPaid) return;
|
if (!verifyUrl || !showQrDialog || isPaid || !isOnline) return;
|
||||||
|
|
||||||
const checkPayment = async () => {
|
const checkPayment = async () => {
|
||||||
// Check if invoice has expired
|
// Check if invoice has expired
|
||||||
@@ -148,6 +148,7 @@ export function ZapWindow({
|
|||||||
setCheckingPayment(true);
|
setCheckingPayment(true);
|
||||||
try {
|
try {
|
||||||
const result = await verifyPayment(verifyUrl);
|
const result = await verifyPayment(verifyUrl);
|
||||||
|
setLastChecked(Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
// If payment is settled, mark as paid and show success
|
// If payment is settled, mark as paid and show success
|
||||||
if (result.status === "OK" && result.settled) {
|
if (result.status === "OK" && result.settled) {
|
||||||
@@ -161,6 +162,7 @@ export function ZapWindow({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silently ignore errors - will retry on next interval
|
// Silently ignore errors - will retry on next interval
|
||||||
console.debug("Payment verification check failed:", error);
|
console.debug("Payment verification check failed:", error);
|
||||||
|
setLastChecked(Math.floor(Date.now() / 1000));
|
||||||
} finally {
|
} finally {
|
||||||
setCheckingPayment(false);
|
setCheckingPayment(false);
|
||||||
}
|
}
|
||||||
@@ -176,11 +178,45 @@ export function ZapWindow({
|
|||||||
showQrDialog,
|
showQrDialog,
|
||||||
isPaid,
|
isPaid,
|
||||||
invoiceExpiry,
|
invoiceExpiry,
|
||||||
|
isOnline,
|
||||||
selectedAmount,
|
selectedAmount,
|
||||||
customAmount,
|
customAmount,
|
||||||
recipientName,
|
recipientName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Network online/offline detection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Countdown timer for invoice expiry
|
||||||
|
useEffect(() => {
|
||||||
|
if (!invoiceExpiry || !showQrDialog) return;
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const remaining = invoiceExpiry - nowSeconds;
|
||||||
|
setTimeRemaining(remaining > 0 ? remaining : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update immediately
|
||||||
|
updateCountdown();
|
||||||
|
|
||||||
|
// Update every second
|
||||||
|
const intervalId = setInterval(updateCountdown, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [invoiceExpiry, showQrDialog]);
|
||||||
|
|
||||||
// Cache LNURL data for recipient's Lightning address
|
// Cache LNURL data for recipient's Lightning address
|
||||||
const { data: lnurlData } = useLnurlCache(recipientProfile?.lud16);
|
const { data: lnurlData } = useLnurlCache(recipientProfile?.lud16);
|
||||||
|
|
||||||
@@ -198,6 +234,9 @@ export function ZapWindow({
|
|||||||
const [verifyUrl, setVerifyUrl] = useState<string>("");
|
const [verifyUrl, setVerifyUrl] = useState<string>("");
|
||||||
const [checkingPayment, setCheckingPayment] = useState(false);
|
const [checkingPayment, setCheckingPayment] = useState(false);
|
||||||
const [invoiceExpiry, setInvoiceExpiry] = useState<number | null>(null); // Unix timestamp when invoice expires
|
const [invoiceExpiry, setInvoiceExpiry] = useState<number | null>(null); // Unix timestamp when invoice expires
|
||||||
|
const [lastChecked, setLastChecked] = useState<number | null>(null); // Unix timestamp of last check
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<number | null>(null); // Seconds until expiry
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
|
||||||
// Editor ref and search functions
|
// Editor ref and search functions
|
||||||
const editorRef = useRef<MentionEditorHandle>(null);
|
const editorRef = useRef<MentionEditorHandle>(null);
|
||||||
@@ -553,6 +592,52 @@ export function ZapWindow({
|
|||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Manual payment verification check
|
||||||
|
const handleManualCheck = async () => {
|
||||||
|
if (!verifyUrl || checkingPayment || isInvoiceExpired) return;
|
||||||
|
|
||||||
|
setCheckingPayment(true);
|
||||||
|
try {
|
||||||
|
const result = await verifyPayment(verifyUrl);
|
||||||
|
setLastChecked(Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
if (result.status === "OK" && result.settled) {
|
||||||
|
setIsPaid(true);
|
||||||
|
setShowQrDialog(false);
|
||||||
|
const amount = selectedAmount || parseInt(customAmount);
|
||||||
|
toast.success(
|
||||||
|
`⚡ Payment received! Zapped ${amount} sats to ${recipientName}!`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.info("Payment not yet received");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Manual payment check failed:", error);
|
||||||
|
toast.error("Failed to check payment status");
|
||||||
|
setLastChecked(Math.floor(Date.now() / 1000));
|
||||||
|
} finally {
|
||||||
|
setCheckingPayment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format time remaining as MM:SS
|
||||||
|
const formatTimeRemaining = (seconds: number): string => {
|
||||||
|
if (seconds <= 0) return "Expired";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format last checked timestamp
|
||||||
|
const formatLastChecked = (timestamp: number | null): string => {
|
||||||
|
if (!timestamp) return "";
|
||||||
|
const secondsAgo = Math.floor(Date.now() / 1000) - timestamp;
|
||||||
|
if (secondsAgo < 5) return "just now";
|
||||||
|
if (secondsAgo < 60) return `${secondsAgo}s ago`;
|
||||||
|
const minsAgo = Math.floor(secondsAgo / 60);
|
||||||
|
return `${minsAgo}m ago`;
|
||||||
|
};
|
||||||
|
|
||||||
// Retry wallet payment
|
// Retry wallet payment
|
||||||
const handleRetryWallet = async () => {
|
const handleRetryWallet = async () => {
|
||||||
if (!invoice || !wallet) return;
|
if (!invoice || !wallet) return;
|
||||||
@@ -612,7 +697,7 @@ export function ZapWindow({
|
|||||||
{/* QR Code */}
|
{/* QR Code */}
|
||||||
{qrCodeUrl && (
|
{qrCodeUrl && (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center p-4 bg-white rounded-lg transition-all ${
|
className={`relative flex justify-center p-4 bg-white rounded-lg transition-all ${
|
||||||
isInvoiceExpired ? "grayscale opacity-50" : ""
|
isInvoiceExpired ? "grayscale opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -621,9 +706,57 @@ export function ZapWindow({
|
|||||||
alt="Lightning Invoice QR Code"
|
alt="Lightning Invoice QR Code"
|
||||||
className="w-64 h-64"
|
className="w-64 h-64"
|
||||||
/>
|
/>
|
||||||
|
{/* Checking animation - subtle pulse on QR border */}
|
||||||
|
{checkingPayment && verifyUrl && !isInvoiceExpired && (
|
||||||
|
<div className="absolute inset-0 rounded-lg border-2 border-yellow-500 animate-pulse pointer-events-none" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status indicators */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{/* Expiry countdown */}
|
||||||
|
{timeRemaining !== null && (
|
||||||
|
<div
|
||||||
|
className={`text-center text-sm ${
|
||||||
|
isInvoiceExpired
|
||||||
|
? "text-destructive font-semibold"
|
||||||
|
: timeRemaining < 300
|
||||||
|
? "text-yellow-600 dark:text-yellow-500 font-medium"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isInvoiceExpired
|
||||||
|
? "⏰ Invoice expired"
|
||||||
|
: `Expires in ${formatTimeRemaining(timeRemaining)}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verification status */}
|
||||||
|
{verifyUrl && !isInvoiceExpired && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{!isOnline ? (
|
||||||
|
<>
|
||||||
|
<span className="size-2 rounded-full bg-gray-400" />
|
||||||
|
<span>Offline - verification paused</span>
|
||||||
|
</>
|
||||||
|
) : checkingPayment ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
<span>Checking...</span>
|
||||||
|
</>
|
||||||
|
) : lastChecked ? (
|
||||||
|
<>
|
||||||
|
<span className="size-2 rounded-full bg-green-500" />
|
||||||
|
<span>
|
||||||
|
Last checked {formatLastChecked(lastChecked)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Amount Preview */}
|
{/* Amount Preview */}
|
||||||
{(selectedAmount || customAmount) && (
|
{(selectedAmount || customAmount) && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -681,11 +814,34 @@ export function ZapWindow({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manual check button if verify URL available */}
|
||||||
|
{verifyUrl && !isInvoiceExpired && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleManualCheck}
|
||||||
|
disabled={checkingPayment}
|
||||||
|
>
|
||||||
|
{checkingPayment ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-4 mr-2" />
|
||||||
|
Check Payment Now
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Always show option to open in external wallet */}
|
{/* Always show option to open in external wallet */}
|
||||||
<Button
|
<Button
|
||||||
variant={paymentTimedOut ? "outline" : "default"}
|
variant={paymentTimedOut || verifyUrl ? "outline" : "default"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => openInWallet(invoice)}
|
onClick={() => openInWallet(invoice)}
|
||||||
|
disabled={isInvoiceExpired}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-4 mr-2" />
|
<ExternalLink className="size-4 mr-2" />
|
||||||
Open in External Wallet
|
Open in External Wallet
|
||||||
|
|||||||
Reference in New Issue
Block a user