Add PayPal checkout provider

This commit is contained in:
Codex Agent
2026-02-04 12:18:14 +01:00
parent 56a39d0535
commit fc5dfb272c
33 changed files with 1586 additions and 571 deletions

View File

@@ -2760,11 +2760,11 @@ export async function getTenantLemonSqueezyTransactions(cursor?: string): Promis
};
}
export async function createTenantLemonSqueezyCheckout(
export async function createTenantPayPalCheckout(
packageId: number,
urls?: { success_url?: string; return_url?: string }
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/lemonsqueezy-checkout', {
): Promise<{ approve_url: string | null; order_id: string; status?: string; checkout_session_id?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2773,7 +2773,7 @@ export async function createTenantLemonSqueezyCheckout(
return_url: urls?.return_url,
}),
});
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
return await jsonOrThrow<{ approve_url: string | null; order_id: string; status?: string; checkout_session_id?: string }>(
response,
'Failed to create checkout'
);
@@ -2854,7 +2854,7 @@ export async function completeTenantPackagePurchase(params: {
const payload: Record<string, unknown> = { package_id: packageId };
if (orderId) {
payload.lemonsqueezy_order_id = orderId;
payload.paypal_order_id = orderId;
}
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { createTenantLemonSqueezyCheckout } from '../../api';
import { createTenantPayPalCheckout } from '../../api';
import { adminPath } from '../../constants';
import { getApiErrorMessage } from '../../lib/apiError';
import { storePendingCheckout } from '../lib/billingCheckout';
@@ -33,7 +33,7 @@ export function usePackageCheckout(): {
cancelUrl.searchParams.set('checkout', 'cancel');
cancelUrl.searchParams.set('package_id', String(packageId));
const { checkout_url, checkout_session_id } = await createTenantLemonSqueezyCheckout(packageId, {
const { approve_url, order_id, checkout_session_id } = await createTenantPayPalCheckout(packageId, {
success_url: successUrl.toString(),
return_url: cancelUrl.toString(),
});
@@ -43,10 +43,15 @@ export function usePackageCheckout(): {
packageId,
checkoutSessionId: checkout_session_id,
startedAt: Date.now(),
orderId: order_id,
});
}
window.location.href = checkout_url;
if (!approve_url) {
throw new Error('PayPal checkout URL missing.');
}
window.location.href = approve_url;
} catch (err) {
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
setBusy(false);

View File

@@ -1,6 +1,7 @@
export type PendingCheckout = {
packageId: number | null;
checkoutSessionId?: string | null;
orderId?: string | null;
startedAt: number;
};
@@ -36,12 +37,14 @@ export function loadPendingCheckout(
? parsed.packageId
: null;
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
const orderId = typeof parsed.orderId === 'string' ? parsed.orderId : null;
if (now - parsed.startedAt > ttl) {
return null;
}
return {
packageId,
checkoutSessionId,
orderId,
startedAt: parsed.startedAt,
};
} catch {

View File

@@ -21,9 +21,11 @@ interface CheckoutWizardPageProps {
error?: string | null;
profile?: OAuthProfilePrefill | null;
};
lemonsqueezy?: {
store_id?: string | null;
test_mode?: boolean | null;
paypal?: {
client_id?: string | null;
currency?: string | null;
intent?: string | null;
locale?: string | null;
};
}
@@ -33,7 +35,7 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
privacyHtml,
googleAuth,
facebookAuth,
lemonsqueezy,
paypal,
}) => {
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null }, flash?: { verification?: { status: string; title?: string; message?: string } } }>();
const currentUser = page.props.auth?.user ?? null;
@@ -93,7 +95,7 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null}
googleProfile={googleProfile}
facebookProfile={facebookProfile}
lemonsqueezy={lemonsqueezy ?? null}
paypal={paypal ?? null}
/>
</div>
</div>

View File

@@ -27,9 +27,11 @@ interface CheckoutWizardProps {
initialStep?: CheckoutStepId;
googleProfile?: OAuthProfilePrefill | null;
facebookProfile?: OAuthProfilePrefill | null;
lemonsqueezy?: {
store_id?: string | null;
test_mode?: boolean | null;
paypal?: {
client_id?: string | null;
currency?: string | null;
intent?: string | null;
locale?: string | null;
} | null;
}
@@ -290,7 +292,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialStep,
googleProfile,
facebookProfile,
lemonsqueezy,
paypal,
}) => {
const [storedGoogleProfile, setStoredGoogleProfile] = useState<OAuthProfilePrefill | null>(() => {
if (typeof window === 'undefined') {
@@ -382,7 +384,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialStep={initialStep}
initialAuthUser={initialAuthUser ?? undefined}
initialIsAuthenticated={Boolean(initialAuthUser)}
lemonsqueezy={lemonsqueezy ?? null}
paypal={paypal ?? null}
>
<WizardBody
privacyHtml={privacyHtml}

View File

@@ -21,9 +21,11 @@ interface CheckoutWizardContextType {
currentStep: CheckoutStepId;
isAuthenticated: boolean;
authUser: unknown;
lemonsqueezyConfig?: {
store_id?: string | null;
test_mode?: boolean | null;
paypalConfig?: {
client_id?: string | null;
currency?: string | null;
intent?: string | null;
locale?: string | null;
} | null;
paymentCompleted: boolean;
checkoutSessionId: string | null;
@@ -119,9 +121,11 @@ interface CheckoutWizardProviderProps {
initialStep?: CheckoutStepId;
initialAuthUser?: unknown;
initialIsAuthenticated?: boolean;
lemonsqueezy?: {
store_id?: string | null;
test_mode?: boolean | null;
paypal?: {
client_id?: string | null;
currency?: string | null;
intent?: string | null;
locale?: string | null;
} | null;
}
@@ -132,7 +136,7 @@ export function CheckoutWizardProvider({
initialStep,
initialAuthUser,
initialIsAuthenticated,
lemonsqueezy,
paypal,
}: CheckoutWizardProviderProps) {
const customInitialState: CheckoutState = {
...initialState,
@@ -153,7 +157,7 @@ export function CheckoutWizardProvider({
if (savedState) {
try {
const parsed = JSON.parse(savedState);
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.lemonsqueezy_variant_id === 'string' && parsed.selectedPackage.lemonsqueezy_variant_id !== '';
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.id === 'number';
if (hasValidPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
// Restore state selectively
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
@@ -284,7 +288,7 @@ export function CheckoutWizardProvider({
currentStep: state.currentStep,
isAuthenticated: state.isAuthenticated,
authUser: state.authUser,
lemonsqueezyConfig: lemonsqueezy ?? null,
paypalConfig: paypal ?? null,
paymentCompleted: state.paymentCompleted,
checkoutSessionId: state.checkoutSessionId,
selectPackage,

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { CheckoutWizardProvider, useCheckoutWizard } from '../WizardContext';
import { PaymentStep } from '../steps/PaymentStep';
import { fireEvent, waitFor } from '@testing-library/react';
@@ -12,7 +12,6 @@ vi.mock('@/hooks/useAnalytics', () => ({
const basePackage = {
id: 1,
price: 49,
lemonsqueezy_variant_id: 'pri_test_123',
};
const StepIndicator = () => {
@@ -31,24 +30,35 @@ const AuthSeeder = () => {
};
describe('PaymentStep', () => {
let paypalOptions: Record<string, any> | null = null;
beforeEach(() => {
localStorage.clear();
window.LemonSqueezy = {
Setup: vi.fn(),
Url: { Open: vi.fn() },
paypalOptions = null;
window.paypal = {
Buttons: (options: Record<string, any>) => {
paypalOptions = options;
return {
render: async () => {},
};
},
};
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
cleanup();
delete window.LemonSqueezy;
delete window.paypal;
vi.unstubAllGlobals();
});
it('renders the payment experience without crashing', async () => {
render(
<CheckoutWizardProvider initialPackage={basePackage} packageOptions={[basePackage]}>
<CheckoutWizardProvider
initialPackage={basePackage}
packageOptions={[basePackage]}
paypal={{ client_id: 'client', currency: 'EUR', intent: 'capture' }}
>
<PaymentStep />
</CheckoutWizardProvider>,
);
@@ -57,7 +67,7 @@ describe('PaymentStep', () => {
expect(screen.queryByText('checkout.legal.checkbox_digital_content_label')).not.toBeInTheDocument();
});
it('skips Lemon Squeezy payment when backend simulates checkout', async () => {
it('completes PayPal checkout when capture succeeds', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementation(async (input) => {
if (typeof input === 'string' && input.includes('/sanctum/csrf-cookie')) {
@@ -66,22 +76,32 @@ describe('PaymentStep', () => {
status: 204,
redirected: false,
url: '',
json: async () => ({}),
text: async () => '',
} as Response;
}
if (typeof input === 'string' && input.includes('/lemonsqueezy/create-checkout')) {
if (typeof input === 'string' && input.includes('/paypal/create-order')) {
const payload = { checkout_session_id: 'session_123', order_id: 'order_123' };
return {
ok: true,
status: 200,
redirected: false,
url: '',
text: async () => JSON.stringify({
simulated: true,
checkout_session_id: 'session_123',
order_id: 'order_123',
id: 'chk_123',
}),
json: async () => payload,
text: async () => JSON.stringify(payload),
} as Response;
}
if (typeof input === 'string' && input.includes('/paypal/capture-order')) {
const payload = { status: 'completed' };
return {
ok: true,
status: 200,
redirected: false,
url: '',
json: async () => payload,
text: async () => JSON.stringify(payload),
} as Response;
}
@@ -90,6 +110,7 @@ describe('PaymentStep', () => {
status: 200,
redirected: false,
url: '',
json: async () => ({}),
text: async () => '',
} as Response;
});
@@ -99,6 +120,7 @@ describe('PaymentStep', () => {
initialPackage={basePackage}
packageOptions={[basePackage]}
initialStep="payment"
paypal={{ client_id: 'client', currency: 'EUR', intent: 'capture' }}
>
<AuthSeeder />
<StepIndicator />
@@ -106,14 +128,24 @@ describe('PaymentStep', () => {
</CheckoutWizardProvider>,
);
fireEvent.click(await screen.findByRole('checkbox'));
const ctas = await screen.findAllByText('checkout.payment_step.pay_with_lemonsqueezy');
fireEvent.click(ctas[0]);
const checkbox = await screen.findByRole('checkbox');
fireEvent.click(checkbox);
await waitFor(() => {
expect(checkbox).toBeChecked();
});
await waitFor(() => {
expect(paypalOptions).not.toBeNull();
});
await act(async () => {
await paypalOptions?.createOrder?.();
await paypalOptions?.onApprove?.({ orderID: 'order_123' });
});
await waitFor(() => {
expect(screen.getByTestId('current-step')).toHaveTextContent('confirmation');
});
expect(window.LemonSqueezy?.Url.Open).not.toHaveBeenCalled();
});
});

View File

@@ -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>