388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import { LoaderCircle } from 'lucide-react';
|
|
import { useCheckoutWizard } from '../WizardContext';
|
|
|
|
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
|
|
|
|
declare global {
|
|
interface Window {
|
|
Paddle?: {
|
|
Environment?: {
|
|
set: (environment: string) => void;
|
|
};
|
|
Initialize?: (options: { token: string }) => void;
|
|
Checkout: {
|
|
open: (options: Record<string, unknown>) => void;
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
|
|
|
|
type PaddleEnvironment = 'sandbox' | 'production';
|
|
|
|
let paddleLoaderPromise: Promise<typeof window.Paddle | null> | null = null;
|
|
|
|
function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null {
|
|
if (!paddle) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
paddle.Environment?.set?.(environment);
|
|
} catch (error) {
|
|
console.warn('[Paddle] Failed to set environment', error);
|
|
}
|
|
|
|
return paddle;
|
|
}
|
|
|
|
async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window.Paddle | null> {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
if (window.Paddle) {
|
|
return configurePaddle(window.Paddle, environment);
|
|
}
|
|
|
|
if (!paddleLoaderPromise) {
|
|
paddleLoaderPromise = new Promise<typeof window.Paddle | null>((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = PADDLE_SCRIPT_URL;
|
|
script.async = true;
|
|
script.onload = () => resolve(window.Paddle ?? null);
|
|
script.onerror = (error) => reject(error);
|
|
document.head.appendChild(script);
|
|
}).catch((error) => {
|
|
console.error('Failed to load Paddle.js', error);
|
|
paddleLoaderPromise = null;
|
|
return null;
|
|
});
|
|
}
|
|
|
|
const paddle = await paddleLoaderPromise;
|
|
|
|
return configurePaddle(paddle, environment);
|
|
}
|
|
|
|
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
|
|
const { t } = useTranslation('marketing');
|
|
|
|
return (
|
|
<Button size="lg" className="w-full" disabled={disabled} onClick={onCheckout}>
|
|
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('checkout.payment_step.pay_with_paddle')}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
export const PaymentStep: React.FC = () => {
|
|
const { t } = useTranslation('marketing');
|
|
const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard();
|
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
|
const [message, setMessage] = useState<string>('');
|
|
const [initialised, setInitialised] = useState(false);
|
|
const [inlineActive, setInlineActive] = useState(false);
|
|
const paddleRef = useRef<typeof window.Paddle | null>(null);
|
|
const eventCallbackRef = useRef<(event: any) => void>();
|
|
const checkoutContainerClass = 'paddle-checkout-container';
|
|
|
|
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
|
|
|
|
const handleFreeActivation = async () => {
|
|
setPaymentCompleted(true);
|
|
nextStep();
|
|
};
|
|
|
|
const startPaddleCheckout = async () => {
|
|
if (!selectedPackage) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedPackage.paddle_price_id) {
|
|
setStatus('error');
|
|
setMessage(t('checkout.payment_step.paddle_not_configured'));
|
|
return;
|
|
}
|
|
|
|
setPaymentCompleted(false);
|
|
setStatus('processing');
|
|
setMessage(t('checkout.payment_step.paddle_preparing'));
|
|
setInlineActive(false);
|
|
|
|
try {
|
|
const inlineSupported = initialised && !!paddleConfig?.client_token;
|
|
|
|
if (typeof window !== 'undefined') {
|
|
// eslint-disable-next-line no-console
|
|
console.info('[Checkout] Paddle inline status', {
|
|
inlineSupported,
|
|
initialised,
|
|
hasClientToken: Boolean(paddleConfig?.client_token),
|
|
environment: paddleConfig?.environment,
|
|
paddlePriceId: selectedPackage.paddle_price_id,
|
|
});
|
|
}
|
|
|
|
if (inlineSupported) {
|
|
const paddle = paddleRef.current;
|
|
|
|
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
|
|
throw new Error('Inline Paddle checkout is not available.');
|
|
}
|
|
|
|
const inlinePayload: Record<string, unknown> = {
|
|
items: [
|
|
{
|
|
priceId: selectedPackage.paddle_price_id,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
settings: {
|
|
displayMode: 'inline',
|
|
frameTarget: checkoutContainerClass,
|
|
frameInitialHeight: '550',
|
|
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
|
|
theme: 'light',
|
|
locale: typeof document !== 'undefined' ? document.documentElement.lang ?? 'de' : 'de',
|
|
},
|
|
customData: {
|
|
package_id: String(selectedPackage.id),
|
|
},
|
|
};
|
|
|
|
const customerEmail = authUser?.email ?? null;
|
|
if (customerEmail) {
|
|
inlinePayload.customer = { email: customerEmail };
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
// eslint-disable-next-line no-console
|
|
console.info('[Checkout] Opening inline Paddle checkout', inlinePayload);
|
|
}
|
|
|
|
paddle.Checkout.open(inlinePayload);
|
|
|
|
setInlineActive(true);
|
|
setStatus('ready');
|
|
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
|
return;
|
|
}
|
|
|
|
const response = await fetch('/paddle/create-checkout', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
|
},
|
|
body: JSON.stringify({
|
|
package_id: selectedPackage.id,
|
|
}),
|
|
});
|
|
|
|
const rawBody = await response.text();
|
|
if (typeof window !== 'undefined') {
|
|
// eslint-disable-next-line no-console
|
|
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
|
}
|
|
|
|
let data: any = null;
|
|
try {
|
|
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
|
} catch (parseError) {
|
|
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
|
|
data = null;
|
|
}
|
|
|
|
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 Paddle checkout.';
|
|
throw new Error(message);
|
|
}
|
|
|
|
window.open(checkoutUrl, '_blank', 'noopener');
|
|
setInlineActive(false);
|
|
setStatus('ready');
|
|
setMessage(t('checkout.payment_step.paddle_ready'));
|
|
} catch (error) {
|
|
console.error('Failed to start Paddle checkout', error);
|
|
setStatus('error');
|
|
setMessage(t('checkout.payment_step.paddle_error'));
|
|
setInlineActive(false);
|
|
setPaymentCompleted(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const environment = paddleConfig?.environment === 'sandbox' ? 'sandbox' : 'production';
|
|
const clientToken = paddleConfig?.client_token ?? null;
|
|
|
|
eventCallbackRef.current = (event) => {
|
|
if (!event?.name) {
|
|
return;
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
// eslint-disable-next-line no-console
|
|
console.debug('[Checkout] Paddle event', event);
|
|
}
|
|
|
|
if (event.name === 'checkout.completed') {
|
|
setStatus('ready');
|
|
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
|
setInlineActive(false);
|
|
setPaymentCompleted(true);
|
|
}
|
|
|
|
if (event.name === 'checkout.closed') {
|
|
setStatus('idle');
|
|
setMessage('');
|
|
setInlineActive(false);
|
|
setPaymentCompleted(false);
|
|
}
|
|
|
|
if (event.name === 'checkout.error') {
|
|
setStatus('error');
|
|
setMessage(t('checkout.payment_step.paddle_error'));
|
|
setInlineActive(false);
|
|
setPaymentCompleted(false);
|
|
}
|
|
};
|
|
|
|
(async () => {
|
|
const paddle = await loadPaddle(environment);
|
|
|
|
if (cancelled || !paddle) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let inlineReady = false;
|
|
if (typeof paddle.Initialize === 'function' && clientToken) {
|
|
if (typeof window !== 'undefined') {
|
|
// eslint-disable-next-line no-console
|
|
console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) });
|
|
}
|
|
paddle.Initialize({
|
|
token: clientToken,
|
|
checkout: {
|
|
settings: {
|
|
displayMode: 'inline',
|
|
frameTarget: checkoutContainerClass,
|
|
frameInitialHeight: '550',
|
|
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
|
|
locale: typeof document !== 'undefined' ? document.documentElement.lang ?? 'de' : 'de',
|
|
},
|
|
},
|
|
eventCallback: (event: any) => eventCallbackRef.current?.(event),
|
|
});
|
|
inlineReady = true;
|
|
}
|
|
|
|
paddleRef.current = paddle;
|
|
setInitialised(inlineReady);
|
|
} catch (error) {
|
|
console.error('Failed to initialize Paddle', error);
|
|
setInitialised(false);
|
|
setStatus('error');
|
|
setMessage(t('checkout.payment_step.paddle_error'));
|
|
setPaymentCompleted(false);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [paddleConfig?.environment, paddleConfig?.client_token, setPaymentCompleted, t]);
|
|
|
|
useEffect(() => {
|
|
setPaymentCompleted(false);
|
|
}, [selectedPackage?.id, setPaymentCompleted]);
|
|
|
|
if (!selectedPackage) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('checkout.payment_step.no_package_title')}</AlertTitle>
|
|
<AlertDescription>{t('checkout.payment_step.no_package_description')}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (isFree) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Alert>
|
|
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
|
|
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
|
</Alert>
|
|
<div className="flex justify-end">
|
|
<Button size="lg" onClick={handleFreeActivation}>
|
|
{t('checkout.payment_step.activate_package')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('checkout.payment_step.paddle_intro')}
|
|
</p>
|
|
|
|
{status !== 'idle' && (
|
|
<Alert variant={status === 'error' ? 'destructive' : 'secondary'}>
|
|
<AlertTitle>
|
|
{status === 'processing'
|
|
? t('checkout.payment_step.status_processing_title')
|
|
: status === 'ready'
|
|
? t('checkout.payment_step.status_ready_title')
|
|
: status === 'error'
|
|
? t('checkout.payment_step.status_error_title')
|
|
: t('checkout.payment_step.status_info_title')}
|
|
</AlertTitle>
|
|
<AlertDescription className="flex items-center gap-3">
|
|
<span>{message}</span>
|
|
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
|
<div className={`${checkoutContainerClass} min-h-[200px]`} />
|
|
{!inlineActive && (
|
|
<PaddleCta
|
|
onCheckout={startPaddleCheckout}
|
|
disabled={status === 'processing'}
|
|
isProcessing={status === 'processing'}
|
|
/>
|
|
)}
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('checkout.payment_step.paddle_disclaimer')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|