|
|
|
|
@@ -19,27 +19,19 @@ import { useAnalytics } from '@/hooks/useAnalytics';
|
|
|
|
|
|
|
|
|
|
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
|
|
|
|
|
|
|
|
|
|
type LemonSqueezyEvent = {
|
|
|
|
|
event?: string;
|
|
|
|
|
data?: Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
createLemonSqueezy?: () => void;
|
|
|
|
|
LemonSqueezy?: {
|
|
|
|
|
Setup: (options: { eventHandler?: (event: LemonSqueezyEvent) => void }) => void;
|
|
|
|
|
Refresh?: () => void;
|
|
|
|
|
Url: {
|
|
|
|
|
Open: (url: string) => void;
|
|
|
|
|
Close?: () => void;
|
|
|
|
|
paypal?: {
|
|
|
|
|
Buttons: (options: Record<string, unknown>) => {
|
|
|
|
|
render: (selector: HTMLElement | string) => Promise<void>;
|
|
|
|
|
close?: () => void;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LEMON_SCRIPT_URL = 'https://app.lemonsqueezy.com/js/lemon.js';
|
|
|
|
|
const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground';
|
|
|
|
|
const PAYPAL_SDK_BASE = 'https://www.paypal.com/sdk/js';
|
|
|
|
|
|
|
|
|
|
const getCookieValue = (name: string): string | null => {
|
|
|
|
|
if (typeof document === 'undefined') {
|
|
|
|
|
@@ -116,54 +108,59 @@ export function resolveCheckoutLocale(rawLocale?: string | null): string {
|
|
|
|
|
return short || 'en';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lemonLoaderPromise: Promise<typeof window.LemonSqueezy | null> | null = null;
|
|
|
|
|
type PayPalSdkOptions = {
|
|
|
|
|
clientId: string;
|
|
|
|
|
currency: string;
|
|
|
|
|
intent: string;
|
|
|
|
|
locale?: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function loadLemonSqueezy(): Promise<typeof window.LemonSqueezy | null> {
|
|
|
|
|
let paypalLoaderPromise: Promise<typeof window.paypal | null> | null = null;
|
|
|
|
|
let paypalLoaderKey: string | null = null;
|
|
|
|
|
|
|
|
|
|
async function loadPayPalSdk(options: PayPalSdkOptions): Promise<typeof window.paypal | null> {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (window.LemonSqueezy) {
|
|
|
|
|
return window.LemonSqueezy;
|
|
|
|
|
if (window.paypal) {
|
|
|
|
|
return window.paypal;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!lemonLoaderPromise) {
|
|
|
|
|
lemonLoaderPromise = new Promise<typeof window.LemonSqueezy | null>((resolve, reject) => {
|
|
|
|
|
const script = document.createElement('script');
|
|
|
|
|
script.src = LEMON_SCRIPT_URL;
|
|
|
|
|
script.defer = true;
|
|
|
|
|
script.onload = () => {
|
|
|
|
|
window.createLemonSqueezy?.();
|
|
|
|
|
resolve(window.LemonSqueezy ?? null);
|
|
|
|
|
};
|
|
|
|
|
script.onerror = (error) => reject(error);
|
|
|
|
|
document.head.appendChild(script);
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
console.error('Failed to load Lemon.js', error);
|
|
|
|
|
lemonLoaderPromise = null;
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
'client-id': options.clientId,
|
|
|
|
|
currency: options.currency,
|
|
|
|
|
intent: options.intent,
|
|
|
|
|
components: 'buttons',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (options.locale) {
|
|
|
|
|
params.set('locale', options.locale);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lemonLoaderPromise;
|
|
|
|
|
const src = `${PAYPAL_SDK_BASE}?${params.toString()}`;
|
|
|
|
|
|
|
|
|
|
if (paypalLoaderPromise && paypalLoaderKey === src) {
|
|
|
|
|
return paypalLoaderPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
paypalLoaderKey = src;
|
|
|
|
|
paypalLoaderPromise = new Promise((resolve, reject) => {
|
|
|
|
|
const script = document.createElement('script');
|
|
|
|
|
script.src = src;
|
|
|
|
|
script.async = true;
|
|
|
|
|
script.onload = () => resolve(window.paypal ?? null);
|
|
|
|
|
script.onerror = (error) => reject(error);
|
|
|
|
|
document.head.appendChild(script);
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
console.error('Failed to load PayPal SDK', error);
|
|
|
|
|
paypalLoaderPromise = null;
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return paypalLoaderPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LemonSqueezyCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
|
|
|
|
|
const { t } = useTranslation('marketing');
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
size="lg"
|
|
|
|
|
className={cn('w-full sm:w-auto', className)}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={onCheckout}
|
|
|
|
|
>
|
|
|
|
|
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
|
|
{t('checkout.payment_step.pay_with_lemonsqueezy')}
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const PaymentStep: React.FC = () => {
|
|
|
|
|
const { t, i18n } = useTranslation('marketing');
|
|
|
|
|
const { trackEvent } = useAnalytics();
|
|
|
|
|
@@ -176,10 +173,10 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
setPaymentCompleted,
|
|
|
|
|
checkoutSessionId,
|
|
|
|
|
setCheckoutSessionId,
|
|
|
|
|
paypalConfig,
|
|
|
|
|
} = useCheckoutWizard();
|
|
|
|
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
|
|
|
|
const [message, setMessage] = useState<string>('');
|
|
|
|
|
const [inlineActive, setInlineActive] = useState(false);
|
|
|
|
|
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
|
|
|
const [consentError, setConsentError] = useState<string | null>(null);
|
|
|
|
|
const [couponCode, setCouponCode] = useState<string>(() => {
|
|
|
|
|
@@ -199,10 +196,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
const [couponError, setCouponError] = useState<string | null>(null);
|
|
|
|
|
const [couponNotice, setCouponNotice] = useState<string | null>(null);
|
|
|
|
|
const [couponLoading, setCouponLoading] = useState(false);
|
|
|
|
|
const lemonRef = useRef<typeof window.LemonSqueezy | null>(null);
|
|
|
|
|
const eventHandlerRef = useRef<(event: LemonSqueezyEvent) => void>();
|
|
|
|
|
const lastCheckoutIdRef = useRef<string | null>(null);
|
|
|
|
|
const hasAutoAppliedCoupon = useRef(false);
|
|
|
|
|
const [showWithdrawalModal, setShowWithdrawalModal] = useState(false);
|
|
|
|
|
const [withdrawalHtml, setWithdrawalHtml] = useState<string | null>(null);
|
|
|
|
|
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
|
|
|
|
|
@@ -212,10 +205,25 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(null);
|
|
|
|
|
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
|
|
|
|
|
const [freeActivationBusy, setFreeActivationBusy] = useState(false);
|
|
|
|
|
const [pendingConfirmation, setPendingConfirmation] = useState<{
|
|
|
|
|
orderId: string | null;
|
|
|
|
|
checkoutId: string | null;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
|
|
|
const paypalContainerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const paypalButtonsRef = useRef<{ close?: () => void } | null>(null);
|
|
|
|
|
const paypalActionsRef = useRef<{ enable: () => void; disable: () => void } | null>(null);
|
|
|
|
|
const checkoutSessionRef = useRef<string | null>(checkoutSessionId);
|
|
|
|
|
const acceptedTermsRef = useRef<boolean>(acceptedTerms);
|
|
|
|
|
const couponCodeRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
checkoutSessionRef.current = checkoutSessionId;
|
|
|
|
|
}, [checkoutSessionId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
acceptedTermsRef.current = acceptedTerms;
|
|
|
|
|
}, [acceptedTerms]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
couponCodeRef.current = couponPreview?.coupon.code ?? null;
|
|
|
|
|
}, [couponPreview]);
|
|
|
|
|
|
|
|
|
|
const checkoutLocale = useMemo(() => {
|
|
|
|
|
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
|
|
|
|
|
@@ -224,31 +232,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
|
|
|
|
|
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
|
|
|
|
|
|
|
|
|
|
const confirmCheckoutSession = useCallback(async (payload: { orderId: string | null; checkoutId: string | null }) => {
|
|
|
|
|
if (!checkoutSessionId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!payload.orderId && !payload.checkoutId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await refreshCheckoutCsrfToken();
|
|
|
|
|
await fetch(`/checkout/session/${checkoutSessionId}/confirm`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: buildCheckoutHeaders(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
order_id: payload.orderId,
|
|
|
|
|
checkout_id: payload.checkoutId,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to confirm Lemon Squeezy session', error);
|
|
|
|
|
}
|
|
|
|
|
}, [checkoutSessionId]);
|
|
|
|
|
|
|
|
|
|
const applyCoupon = useCallback(async (code: string) => {
|
|
|
|
|
if (!selectedPackage) {
|
|
|
|
|
return;
|
|
|
|
|
@@ -310,12 +293,7 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
}, [RateLimitHelper, selectedPackage, t, trackEvent]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (hasAutoAppliedCoupon.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (couponCode && selectedPackage) {
|
|
|
|
|
hasAutoAppliedCoupon.current = true;
|
|
|
|
|
applyCoupon(couponCode);
|
|
|
|
|
}
|
|
|
|
|
}, [applyCoupon, couponCode, selectedPackage]);
|
|
|
|
|
@@ -324,7 +302,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
setCouponPreview(null);
|
|
|
|
|
setCouponNotice(null);
|
|
|
|
|
setCouponError(null);
|
|
|
|
|
hasAutoAppliedCoupon.current = false;
|
|
|
|
|
}, [selectedPackage?.id]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
@@ -383,7 +360,7 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.lemonsqueezy_error');
|
|
|
|
|
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paypal_error');
|
|
|
|
|
setConsentError(errorMessage);
|
|
|
|
|
toast.error(errorMessage);
|
|
|
|
|
return;
|
|
|
|
|
@@ -394,7 +371,7 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
nextStep();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to activate free package', error);
|
|
|
|
|
const fallbackMessage = t('checkout.payment_step.lemonsqueezy_error');
|
|
|
|
|
const fallbackMessage = t('checkout.payment_step.paypal_error');
|
|
|
|
|
setConsentError(fallbackMessage);
|
|
|
|
|
toast.error(fallbackMessage);
|
|
|
|
|
} finally {
|
|
|
|
|
@@ -402,216 +379,192 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startLemonSqueezyCheckout = async () => {
|
|
|
|
|
if (!selectedPackage) {
|
|
|
|
|
return;
|
|
|
|
|
const handlePayPalCapture = useCallback(async (orderId: string) => {
|
|
|
|
|
const sessionId = checkoutSessionRef.current;
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
throw new Error('Missing checkout session');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isAuthenticated || !authUser) {
|
|
|
|
|
const message = t('checkout.payment_step.auth_required');
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(message);
|
|
|
|
|
toast.error(message);
|
|
|
|
|
goToStep('auth');
|
|
|
|
|
return;
|
|
|
|
|
await refreshCheckoutCsrfToken();
|
|
|
|
|
const response = await fetch('/paypal/capture-order', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: buildCheckoutHeaders(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
checkout_session_id: sessionId,
|
|
|
|
|
order_id: orderId,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(payload?.message || t('checkout.payment_step.paypal_error'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!acceptedTerms) {
|
|
|
|
|
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!selectedPackage.lemonsqueezy_variant_id) {
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.lemonsqueezy_not_configured'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.lemonsqueezy_preparing'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setCheckoutSessionId(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await refreshCheckoutCsrfToken();
|
|
|
|
|
const response = await fetch('/lemonsqueezy/create-checkout', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: buildCheckoutHeaders(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
package_id: selectedPackage.id,
|
|
|
|
|
locale: checkoutLocale,
|
|
|
|
|
coupon_code: couponPreview?.coupon.code ?? undefined,
|
|
|
|
|
accepted_terms: acceptedTerms,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rawBody = await response.text();
|
|
|
|
|
if (
|
|
|
|
|
response.status === 401 ||
|
|
|
|
|
response.status === 419 ||
|
|
|
|
|
(response.redirected && response.url.includes('/login'))
|
|
|
|
|
) {
|
|
|
|
|
const message = t('checkout.payment_step.auth_required');
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(message);
|
|
|
|
|
toast.error(message);
|
|
|
|
|
goToStep('auth');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
|
|
|
|
|
console.info('[Checkout] Lemon Squeezy checkout response', { status: response.status, rawBody });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data: { checkout_url?: string; message?: string; simulated?: boolean; order_id?: string; checkout_id?: string; id?: string; checkout_session_id?: string } | null = null;
|
|
|
|
|
try {
|
|
|
|
|
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
|
|
|
|
} catch (parseError) {
|
|
|
|
|
console.warn('Failed to parse Lemon Squeezy checkout payload as JSON', parseError);
|
|
|
|
|
data = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const checkoutSession = data?.checkout_session_id ?? null;
|
|
|
|
|
if (checkoutSession && typeof checkoutSession === 'string') {
|
|
|
|
|
setCheckoutSessionId(checkoutSession);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data?.simulated) {
|
|
|
|
|
const orderId = typeof data.order_id === 'string' ? data.order_id : null;
|
|
|
|
|
const checkoutId = typeof data.checkout_id === 'string'
|
|
|
|
|
? data.checkout_id
|
|
|
|
|
: (typeof data.id === 'string' ? data.id : null);
|
|
|
|
|
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.processing_confirmation'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setPendingConfirmation({ orderId, checkoutId });
|
|
|
|
|
setPaymentCompleted(true);
|
|
|
|
|
nextStep();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null;
|
|
|
|
|
|
|
|
|
|
if (!checkoutUrl) {
|
|
|
|
|
const trimmed = rawBody.trim();
|
|
|
|
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
|
|
|
checkoutUrl = trimmed;
|
|
|
|
|
} else if (trimmed.startsWith('<')) {
|
|
|
|
|
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/);
|
|
|
|
|
if (match) {
|
|
|
|
|
checkoutUrl = match[0];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok || !checkoutUrl) {
|
|
|
|
|
const message = data?.message || rawBody || 'Unable to create Lemon Squeezy checkout.';
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data && typeof (data as { id?: string }).id === 'string') {
|
|
|
|
|
lastCheckoutIdRef.current = (data as { id?: string }).id ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lemon = await loadLemonSqueezy();
|
|
|
|
|
|
|
|
|
|
if (lemon?.Url?.Open) {
|
|
|
|
|
lemon.Url.Open(checkoutUrl);
|
|
|
|
|
setInlineActive(true);
|
|
|
|
|
setStatus('ready');
|
|
|
|
|
setMessage(t('checkout.payment_step.lemonsqueezy_overlay_ready'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.open(checkoutUrl, '_blank', 'noopener');
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setStatus('ready');
|
|
|
|
|
setMessage(t('checkout.payment_step.lemonsqueezy_ready'));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to start Lemon Squeezy checkout', error);
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.lemonsqueezy_error'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
return payload;
|
|
|
|
|
}, [t]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedPackage || isFree) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const clientId = paypalConfig?.client_id ?? null;
|
|
|
|
|
if (!clientId) {
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_not_configured'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
const lemon = await loadLemonSqueezy();
|
|
|
|
|
const initButtons = async () => {
|
|
|
|
|
const paypal = await loadPayPalSdk({
|
|
|
|
|
clientId,
|
|
|
|
|
currency: paypalConfig?.currency ?? 'EUR',
|
|
|
|
|
intent: paypalConfig?.intent ?? 'capture',
|
|
|
|
|
locale: paypalConfig?.locale ?? checkoutLocale,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (cancelled || !lemon) {
|
|
|
|
|
if (cancelled || !paypal || !paypalContainerRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
eventHandlerRef.current = (event) => {
|
|
|
|
|
if (!event?.event) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
|
|
|
|
|
console.debug('[Checkout] Lemon Squeezy event', event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.event === 'Checkout.Success') {
|
|
|
|
|
const data = event.data as { id?: string; identifier?: string; attributes?: { checkout_id?: string } } | undefined;
|
|
|
|
|
const orderId = typeof data?.id === 'string' ? data.id : (typeof data?.identifier === 'string' ? data.identifier : null);
|
|
|
|
|
const checkoutId = typeof data?.attributes?.checkout_id === 'string'
|
|
|
|
|
? data?.attributes?.checkout_id
|
|
|
|
|
: lastCheckoutIdRef.current;
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.processing_confirmation'));
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
setPendingConfirmation({ orderId, checkoutId });
|
|
|
|
|
toast.success(t('checkout.payment_step.toast_success'));
|
|
|
|
|
setPaymentCompleted(true);
|
|
|
|
|
nextStep();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
lemon.Setup({
|
|
|
|
|
eventHandler: (event) => eventHandlerRef.current?.(event),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
lemonRef.current = lemon;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to initialize Lemon.js', error);
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.lemonsqueezy_error'));
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
if (paypalButtonsRef.current?.close) {
|
|
|
|
|
paypalButtonsRef.current.close();
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
paypalContainerRef.current.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
paypalButtonsRef.current = paypal.Buttons({
|
|
|
|
|
onInit: (_data: unknown, actions: { enable: () => void; disable: () => void }) => {
|
|
|
|
|
paypalActionsRef.current = actions;
|
|
|
|
|
if (!acceptedTermsRef.current) {
|
|
|
|
|
actions.disable();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
createOrder: async () => {
|
|
|
|
|
if (!selectedPackage) {
|
|
|
|
|
throw new Error('Missing package');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isAuthenticated || !authUser) {
|
|
|
|
|
const authMessage = t('checkout.payment_step.auth_required');
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(authMessage);
|
|
|
|
|
toast.error(authMessage);
|
|
|
|
|
goToStep('auth');
|
|
|
|
|
throw new Error(authMessage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!acceptedTermsRef.current) {
|
|
|
|
|
const consentMessage = t('checkout.legal.checkbox_terms_error');
|
|
|
|
|
setConsentError(consentMessage);
|
|
|
|
|
throw new Error(consentMessage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setConsentError(null);
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_preparing'));
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
setCheckoutSessionId(null);
|
|
|
|
|
|
|
|
|
|
await refreshCheckoutCsrfToken();
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/paypal/create-order', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: buildCheckoutHeaders(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
package_id: selectedPackage.id,
|
|
|
|
|
locale: checkoutLocale,
|
|
|
|
|
coupon_code: couponCodeRef.current ?? undefined,
|
|
|
|
|
accepted_terms: acceptedTermsRef.current,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorMessage = payload?.message || t('checkout.payment_step.paypal_error');
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(errorMessage);
|
|
|
|
|
throw new Error(errorMessage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload?.checkout_session_id) {
|
|
|
|
|
setCheckoutSessionId(payload.checkout_session_id);
|
|
|
|
|
checkoutSessionRef.current = payload.checkout_session_id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const orderId = payload?.order_id;
|
|
|
|
|
if (!orderId) {
|
|
|
|
|
throw new Error('PayPal order ID missing.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStatus('ready');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_ready'));
|
|
|
|
|
return orderId;
|
|
|
|
|
},
|
|
|
|
|
onApprove: async (data: { orderID?: string }) => {
|
|
|
|
|
if (!data?.orderID) {
|
|
|
|
|
throw new Error('Missing PayPal order ID.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStatus('processing');
|
|
|
|
|
setMessage(t('checkout.payment_step.processing_confirmation'));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = await handlePayPalCapture(data.orderID);
|
|
|
|
|
if (payload?.status === 'completed') {
|
|
|
|
|
setPaymentCompleted(true);
|
|
|
|
|
toast.success(t('checkout.payment_step.toast_success'));
|
|
|
|
|
nextStep();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_error'));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to capture PayPal order', error);
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_error'));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onCancel: () => {
|
|
|
|
|
setStatus('idle');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_cancelled'));
|
|
|
|
|
},
|
|
|
|
|
onError: (error: unknown) => {
|
|
|
|
|
console.error('PayPal button error', error);
|
|
|
|
|
setStatus('error');
|
|
|
|
|
setMessage(t('checkout.payment_step.paypal_error'));
|
|
|
|
|
},
|
|
|
|
|
}) as { render: (selector: HTMLElement | string) => Promise<void>; close?: () => void };
|
|
|
|
|
|
|
|
|
|
await paypalButtonsRef.current.render(paypalContainerRef.current);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void initButtons();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
if (paypalButtonsRef.current?.close) {
|
|
|
|
|
paypalButtonsRef.current.close();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [nextStep, setPaymentCompleted, t]);
|
|
|
|
|
}, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setPaymentCompleted(false);
|
|
|
|
|
setCheckoutSessionId(null);
|
|
|
|
|
setStatus('idle');
|
|
|
|
|
setMessage('');
|
|
|
|
|
setInlineActive(false);
|
|
|
|
|
setPendingConfirmation(null);
|
|
|
|
|
}, [selectedPackage?.id, setPaymentCompleted]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!pendingConfirmation || !checkoutSessionId) {
|
|
|
|
|
return;
|
|
|
|
|
if (paypalActionsRef.current) {
|
|
|
|
|
if (acceptedTerms) {
|
|
|
|
|
paypalActionsRef.current.enable();
|
|
|
|
|
} else {
|
|
|
|
|
paypalActionsRef.current.disable();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void confirmCheckoutSession(pendingConfirmation);
|
|
|
|
|
setPendingConfirmation(null);
|
|
|
|
|
}, [checkoutSessionId, confirmCheckoutSession, pendingConfirmation]);
|
|
|
|
|
}, [acceptedTerms]);
|
|
|
|
|
|
|
|
|
|
const handleCouponSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
@@ -665,7 +618,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
}
|
|
|
|
|
}, [checkoutLocale, t, withdrawalHtml, withdrawalLoading]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!selectedPackage) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
@@ -738,7 +690,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
|
|
|
|
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
|
|
|
|
<Icon className="h-4 w-4 text-white/80" />
|
|
|
|
|
@@ -746,10 +697,10 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const LemonSqueezyLogo = () => (
|
|
|
|
|
const PayPalBadge = () => (
|
|
|
|
|
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
|
|
|
|
|
<span className="text-sm font-semibold tracking-wide">Lemon Squeezy</span>
|
|
|
|
|
<span className="text-xs font-semibold">{t('checkout.payment_step.lemonsqueezy_partner')}</span>
|
|
|
|
|
<span className="text-sm font-semibold tracking-wide">PayPal</span>
|
|
|
|
|
<span className="text-xs font-semibold">{t('checkout.payment_step.paypal_partner')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
@@ -757,11 +708,10 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{!inlineActive && (
|
|
|
|
|
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
|
|
|
|
|
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#0b1f4b] via-[#003087] to-[#009cde] p-6 text-white shadow-md">
|
|
|
|
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<LemonSqueezyLogo />
|
|
|
|
|
<PayPalBadge />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
|
|
|
|
|
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
|
|
|
|
|
@@ -785,7 +735,7 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
setConsentError(null);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
|
|
|
|
|
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#003087]"
|
|
|
|
|
/>
|
|
|
|
|
<div className="space-y-1 text-sm">
|
|
|
|
|
<Label htmlFor="checkout-terms-hero" className="cursor-pointer text-white">
|
|
|
|
|
@@ -817,12 +767,7 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<LemonSqueezyCta
|
|
|
|
|
onCheckout={startLemonSqueezyCheckout}
|
|
|
|
|
disabled={status === 'processing' || !acceptedTerms}
|
|
|
|
|
isProcessing={status === 'processing'}
|
|
|
|
|
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
|
|
|
|
|
/>
|
|
|
|
|
<div ref={paypalContainerRef} className={cn('min-h-[44px]', PRIMARY_CTA_STYLES)} />
|
|
|
|
|
<p className="text-xs text-white/70 text-center">
|
|
|
|
|
{t('checkout.payment_step.guided_cta_hint')}
|
|
|
|
|
</p>
|
|
|
|
|
@@ -831,7 +776,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
|
|
|
|
|
@@ -907,20 +851,6 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!inlineActive && (
|
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{t('checkout.payment_step.lemonsqueezy_intro')}
|
|
|
|
|
</p>
|
|
|
|
|
<LemonSqueezyCta
|
|
|
|
|
onCheckout={startLemonSqueezyCheckout}
|
|
|
|
|
disabled={status === 'processing' || !acceptedTerms}
|
|
|
|
|
isProcessing={status === 'processing'}
|
|
|
|
|
className={PRIMARY_CTA_STYLES}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{status !== 'idle' && (
|
|
|
|
|
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
|
|
|
|
|
<AlertTitle>
|
|
|
|
|
@@ -939,8 +869,8 @@ export const PaymentStep: React.FC = () => {
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
|
|
|
|
|
{t('checkout.payment_step.lemonsqueezy_disclaimer')}
|
|
|
|
|
<p className="text-xs text-muted-foreground sm:text-right">
|
|
|
|
|
{t('checkout.payment_step.paypal_disclaimer')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|