Files
fotospiel-app/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx

421 lines
13 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';
const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no'];
export function resolvePaddleLocale(rawLocale?: string | null): string {
if (!rawLocale) {
return 'en';
}
const normalized = rawLocale.toLowerCase();
if (PADDLE_SUPPORTED_LOCALES.includes(normalized)) {
return normalized;
}
const short = normalized.split('-')[0];
if (short && PADDLE_SUPPORTED_LOCALES.includes(short)) {
return short;
}
return 'en';
}
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 sm:w-auto" 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, i18n } = 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 paddleLocale = useMemo(() => {
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
return resolvePaddleLocale(sourceLocale ?? undefined);
}, [i18n.language]);
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: paddleLocale,
},
customData: {
package_id: String(selectedPackage.id),
locale: paddleLocale,
},
};
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,
locale: paddleLocale,
}),
});
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: paddleLocale,
},
},
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, paddleLocale, 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-4">
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="space-y-4">
{!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.paddle_intro')}
</p>
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
/>
</div>
)}
{status !== 'idle' && (
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
<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={`${checkoutContainerClass} min-h-[360px]`} />
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
{t('checkout.payment_step.paddle_disclaimer')}
</p>
</div>
</div>
</div>
);
};