nac-presale/client/src/components/PaypalPayment.tsx

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PaypalPayment Component
* ─────────────────────────────────────────────────────────────────────────────
* Handles PayPal Orders API v2 payment flow.
*
* Flow:
* 1. User enters USD amount
* 2. Component calls payment.createPaypalOrder mutation
* 3. Redirects user to PayPal approval URL
* 4. After approval, PayPal redirects back with ?token=PAYPAL_ORDER_ID
* 5. Component calls payment.capturePaypalOrder to finalize payment
* 6. Polls payment.queryPaypalOrder for status
* 7. On success (dbStatus === "distributed"), shows success message
*
* URL Parameters handled:
* ?paypalReturn=1&orderId=INTERNAL_ORDER_ID&token=PAYPAL_ORDER_ID
*/
import { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
const XIC_PRICE_USD = 0.02;
function calcXicFromUsd(usd: number): number {
return Math.floor(usd / XIC_PRICE_USD);
}
interface PaypalPaymentProps {
xicReceiveAddress: string;
onSuccess?: (xicAmount: number, orderId: string) => void;
}
export default function PaypalPayment({ xicReceiveAddress, onSuccess }: PaypalPaymentProps) {
const [usdInput, setUsdInput] = useState("100");
const [orderId, setOrderId] = useState<string | null>(null);
const [paypalOrderId, setPaypalOrderId] = useState<string | null>(null);
const [paymentStatus, setPaymentStatus] = useState<"idle" | "redirecting" | "capturing" | "waiting" | "success" | "failed">("idle");
const usdAmount = parseFloat(usdInput) || 0;
const xicAmount = calcXicFromUsd(usdAmount);
// Check URL params on mount — handle PayPal return redirect
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const isReturn = params.get("paypalReturn") === "1";
const returnOrderId = params.get("orderId");
const returnToken = params.get("token"); // PayPal order ID
if (isReturn && returnOrderId && returnToken) {
// Clean URL
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
setOrderId(returnOrderId);
setPaypalOrderId(returnToken);
setPaymentStatus("capturing");
// Auto-capture
captureOrder.mutate({ paypalOrderId: returnToken, internalOrderId: returnOrderId });
}
// Check for cancel
if (params.get("paypalCancel") === "1") {
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
toast.info("PayPal payment was cancelled.");
}
}, []);
const createOrder = trpc.payment.createPaypalOrder.useMutation({
onSuccess: (data) => {
setOrderId(data.orderId);
setPaypalOrderId(data.paypalOrderId || null);
if (data.approveUrl) {
setPaymentStatus("redirecting");
toast.info("Redirecting to PayPal...");
setTimeout(() => {
window.location.href = data.approveUrl!;
}, 1000);
}
},
onError: (err) => {
toast.error(`Failed to create PayPal order: ${err.message}`);
setPaymentStatus("idle");
},
});
const captureOrder = trpc.payment.capturePaypalOrder.useMutation({
onSuccess: (data) => {
if (data.success) {
setPaymentStatus("waiting");
toast.success("Payment captured! Distributing XIC tokens...");
}
},
onError: (err) => {
toast.error(`Failed to capture payment: ${err.message}`);
setPaymentStatus("failed");
},
});
const queryOrder = trpc.payment.queryPaypalOrder.useQuery(
{ orderId: orderId! },
{
enabled: !!orderId && (paymentStatus === "waiting" || paymentStatus === "capturing"),
refetchInterval: 5000,
refetchIntervalInBackground: true,
}
);
useEffect(() => {
if (!queryOrder.data) return;
const { dbStatus, xicAmount: dbXicAmount } = queryOrder.data as any;
if (dbStatus === "distributed" || dbStatus === "paid") {
setPaymentStatus("success");
onSuccess?.(parseFloat(dbXicAmount || "0"), orderId!);
toast.success(`Payment confirmed! ${parseFloat(dbXicAmount || "0").toLocaleString()} XIC tokens will be distributed.`);
} else if (dbStatus === "failed" || dbStatus === "expired") {
setPaymentStatus("failed");
toast.error("Payment failed or expired. Please try again.");
}
}, [queryOrder.data]);
const handlePay = () => {
if (!xicReceiveAddress || xicReceiveAddress.length < 10) {
toast.error("Please enter your XIC receive address first.");
return;
}
if (usdAmount < 1) {
toast.error("Minimum payment is $1 USD.");
return;
}
createOrder.mutate({
usdAmount: usdAmount.toFixed(2),
xicReceiveAddress,
});
};
const handleReset = () => {
setOrderId(null);
setPaypalOrderId(null);
setPaymentStatus("idle");
setUsdInput("100");
};
// ── Success State ──────────────────────────────────────────────────────────
if (paymentStatus === "success") {
return (
<div className="space-y-4 text-center py-6">
<div className="text-5xl mb-3">🎉</div>
<h3 className="text-xl font-bold" style={{ color: "#00e676", fontFamily: "'Space Grotesk', sans-serif" }}>
Payment Successful!
</h3>
<p style={{ color: "rgba(255,255,255,0.7)" }}>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>
{xicAmount.toLocaleString()}
</span>{" "}
XIC tokens are being distributed to your address.
</p>
<button onClick={handleReset} className="px-6 py-2 rounded-lg text-sm font-semibold transition-all"
style={{ background: "rgba(0,212,255,0.15)", border: "1px solid rgba(0,212,255,0.4)", color: "#00d4ff" }}>
Make Another Purchase
</button>
</div>
);
}
// ── Redirecting / Capturing State ──────────────────────────────────────────
if (paymentStatus === "redirecting" || paymentStatus === "capturing" || paymentStatus === "waiting") {
return (
<div className="space-y-4">
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(0,112,186,0.08)", border: "1px solid rgba(0,112,186,0.3)" }}>
<div className="text-3xl mb-2">
{paymentStatus === "redirecting" ? "🔄" : paymentStatus === "capturing" ? "⚡" : "⏳"}
</div>
<p className="font-semibold mb-1" style={{ color: "#009cde" }}>
{paymentStatus === "redirecting" && "Redirecting to PayPal..."}
{paymentStatus === "capturing" && "Capturing Payment..."}
{paymentStatus === "waiting" && "Processing Payment..."}
</p>
{orderId && (
<p className="text-xs" style={{ color: "rgba(255,255,255,0.3)" }}>
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{orderId}</span>
</p>
)}
{paymentStatus === "waiting" && (
<p className="text-xs mt-2" style={{ color: "rgba(255,255,255,0.4)" }}>
Checking payment status every 5 seconds...
</p>
)}
</div>
{paymentStatus !== "redirecting" && (
<button onClick={handleReset} className="w-full py-2 rounded-lg text-sm transition-all"
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}>
Cancel / Start Over
</button>
)}
</div>
);
}
// ── Input State ────────────────────────────────────────────────────────────
return (
<div className="space-y-4">
{/* Amount Input */}
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: "rgba(255,255,255,0.7)" }}>
Payment Amount (USD $)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold" style={{ color: "#009cde" }}>$</span>
<input
type="number"
value={usdInput}
onChange={(e) => setUsdInput(e.target.value)}
min="1"
step="1"
className="w-full pl-8 pr-4 py-3 rounded-xl text-white font-mono focus:outline-none"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(0,112,186,0.3)", fontSize: "1rem" }}
placeholder="100"
/>
</div>
<div className="flex gap-2">
{[50, 100, 500, 1000].map((amt) => (
<button key={amt} onClick={() => setUsdInput(String(amt))}
className="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: usdAmount === amt ? "rgba(0,112,186,0.2)" : "rgba(255,255,255,0.05)",
border: `1px solid ${usdAmount === amt ? "rgba(0,112,186,0.5)" : "rgba(255,255,255,0.1)"}`,
color: usdAmount === amt ? "#009cde" : "rgba(255,255,255,0.5)",
}}>
${amt}
</button>
))}
</div>
</div>
{/* Conversion Preview */}
{usdAmount > 0 && (
<div className="rounded-xl p-3 space-y-1" style={{ background: "rgba(0,112,186,0.05)", border: "1px solid rgba(0,112,186,0.15)" }}>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}>USD Amount</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${usdAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span style={{ color: "rgba(255,255,255,0.5)" }}>XIC Price</span>
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${XIC_PRICE_USD} / XIC</span>
</div>
<div className="flex justify-between text-sm font-semibold">
<span style={{ color: "rgba(255,255,255,0.7)" }}>XIC Tokens</span>
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>{xicAmount.toLocaleString()} XIC</span>
</div>
</div>
)}
{/* PayPal Button */}
<button
onClick={handlePay}
disabled={createOrder.isPending || usdAmount < 1 || !xicReceiveAddress}
className="w-full py-3 rounded-xl text-base font-bold transition-all"
style={{
background: createOrder.isPending ? "rgba(255,255,255,0.1)" : "#ffc439",
color: "#003087",
cursor: createOrder.isPending ? "not-allowed" : "pointer",
fontFamily: "'Space Grotesk', sans-serif",
}}
>
{createOrder.isPending ? (
<span className="flex items-center justify-center gap-2" style={{ color: "#003087" }}>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30 70" />
</svg>
Creating Order...
</span>
) : (
<span className="flex items-center justify-center gap-2">
{/* PayPal logo */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#003087"/>
<path d="M9 7h4.5C15.5 7 17 8.5 17 10.5C17 13 15 14.5 12.5 14.5H11L10 18H8L9 7Z" fill="white"/>
<path d="M10.5 8.5H13C14.5 8.5 15.5 9.5 15.5 11C15.5 12.5 14.5 13 13 13H11.5L10.5 8.5Z" fill="#009cde"/>
</svg>
Pay with PayPal
</span>
)}
</button>
{/* Info */}
<div className="rounded-lg p-3 text-xs"
style={{ background: "rgba(0,112,186,0.08)", border: "1px solid rgba(0,112,186,0.2)", color: "rgba(255,255,255,0.5)" }}>
<p> Supports PayPal balance, credit/debit cards via PayPal</p>
<p> You will be redirected to PayPal to complete payment</p>
<p> XIC tokens distributed within 15 minutes after payment confirmation</p>
<p> PayPal buyer protection applies to this transaction</p>
</div>
</div>
);
}