300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
||
* 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 1–5 minutes after payment confirmation</p>
|
||
<p>• PayPal buyer protection applies to this transaction</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|