Add PayPal checkout provider
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"faq_q3": "Was passiert bei Ablauf?",
|
||||
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
||||
"faq_q4": "Zahlungssicher?",
|
||||
"faq_a4": "Ja, via Lemon Squeezy – sicher und GDPR-konform.",
|
||||
"faq_a4": "Ja, via PayPal - sicher und DSGVO-konform.",
|
||||
"final_cta": "Bereit für Ihr nächstes Event?",
|
||||
"contact_us": "Kontaktieren Sie uns",
|
||||
"feature_live_slideshow": "Live-Slideshow",
|
||||
@@ -259,7 +259,11 @@
|
||||
"text": "Classic-Level ist ein guter Mittelweg für verschiedene Event-Typen."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"order_hint": "Sofort startklar - sichere Zahlung ueber PayPal, keine versteckten Kosten.",
|
||||
"lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.",
|
||||
"lemonsqueezy_checkout_failed": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.",
|
||||
"paypal_checkout_failed": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Fotospiel - Blog",
|
||||
@@ -459,5 +463,30 @@
|
||||
"currency": {
|
||||
"euro": "€"
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
"payment_step": {
|
||||
"secure_payment_desc": "Sichere Zahlung ueber PayPal.",
|
||||
"lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.",
|
||||
"guided_title": "Sichere Zahlung mit PayPal",
|
||||
"guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"guided_cta_hint": "Sicher abgewickelt ueber PayPal",
|
||||
"lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...",
|
||||
"lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.",
|
||||
"lemonsqueezy_ready": "PayPal-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.",
|
||||
"lemonsqueezy_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"lemonsqueezy_not_ready": "Der PayPal-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.",
|
||||
"lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.",
|
||||
"lemonsqueezy_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.",
|
||||
"pay_with_lemonsqueezy": "Weiter mit PayPal",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "PayPal-Checkout wird vorbereitet...",
|
||||
"paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.",
|
||||
"paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.",
|
||||
"paypal_cancelled": "PayPal-Checkout wurde abgebrochen.",
|
||||
"paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ return [
|
||||
'faq_q3' => 'Was passiert bei Ablauf?',
|
||||
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
|
||||
'faq_q4' => 'Zahlungssicher?',
|
||||
'faq_a4' => 'Ja, via Lemon Squeezy – sicher und GDPR-konform.',
|
||||
'faq_a4' => 'Ja, via PayPal – sicher und GDPR-konform.',
|
||||
'final_cta' => 'Bereit für Ihr nächstes Event?',
|
||||
'contact_us' => 'Kontaktieren Sie uns',
|
||||
'feature_live_slideshow' => 'Live-Slideshow',
|
||||
@@ -64,12 +64,13 @@ return [
|
||||
'gallery_days_label' => 'Galerie-Tage',
|
||||
'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'feature_overview' => 'Feature-Überblick',
|
||||
'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Lemon Squeezy.',
|
||||
'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über PayPal.',
|
||||
'features_label' => 'Features',
|
||||
'breakdown_label' => 'Leistungsübersicht',
|
||||
'limits_label' => 'Limits & Kapazitäten',
|
||||
'lemonsqueezy_not_configured' => 'Dieses Package ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.',
|
||||
'lemonsqueezy_checkout_failed' => 'Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
|
||||
'lemonsqueezy_not_configured' => 'Dieses Package ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.',
|
||||
'lemonsqueezy_checkout_failed' => 'Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
|
||||
'paypal_checkout_failed' => 'Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
|
||||
'package_not_found' => 'Dieses Package ist nicht verfügbar. Bitte wähle ein anderes aus.',
|
||||
],
|
||||
'nav' => [
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"faq_q3": "What happens when it expires?",
|
||||
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend it.",
|
||||
"faq_q4": "Payment secure?",
|
||||
"faq_a4": "Yes, via Lemon Squeezy – secure and GDPR-compliant.",
|
||||
"faq_a4": "Yes, via PayPal - secure and GDPR-compliant.",
|
||||
"final_cta": "Ready for your next event?",
|
||||
"contact_us": "Contact Us",
|
||||
"feature_live_slideshow": "Live Slideshow",
|
||||
@@ -260,7 +260,11 @@
|
||||
"text": "Classic level is a solid middle ground for varied event types."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"order_hint": "Ready to launch instantly - secure PayPal checkout, no hidden fees.",
|
||||
"lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.",
|
||||
"lemonsqueezy_checkout_failed": "We could not start the PayPal checkout. Please try again later.",
|
||||
"paypal_checkout_failed": "We could not start the PayPal checkout. Please try again later."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Fotospiel - Blog",
|
||||
@@ -455,5 +459,30 @@
|
||||
},
|
||||
"currency": {
|
||||
"euro": "€"
|
||||
},
|
||||
"checkout": {
|
||||
"payment_step": {
|
||||
"secure_payment_desc": "Secure payment with PayPal.",
|
||||
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
||||
"guided_title": "Secure checkout with PayPal",
|
||||
"guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"guided_cta_hint": "Securely processed via PayPal",
|
||||
"lemonsqueezy_preparing": "Preparing PayPal checkout...",
|
||||
"lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
||||
"lemonsqueezy_ready": "PayPal checkout opened in a new tab. Complete the payment and then continue here.",
|
||||
"lemonsqueezy_error": "We could not start the PayPal checkout. Please try again.",
|
||||
"lemonsqueezy_not_ready": "PayPal checkout is not ready yet. Please try again in a moment.",
|
||||
"lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.",
|
||||
"lemonsqueezy_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.",
|
||||
"pay_with_lemonsqueezy": "Continue with PayPal",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "Preparing PayPal checkout...",
|
||||
"paypal_ready": "PayPal checkout is ready. Complete the payment to continue.",
|
||||
"paypal_error": "We could not start the PayPal checkout. Please try again.",
|
||||
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
||||
"paypal_cancelled": "PayPal checkout was cancelled.",
|
||||
"paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ return [
|
||||
'faq_q3' => 'What happens when it expires?',
|
||||
'faq_a3' => 'The gallery remains readable, but uploads are blocked. Simply extend it.',
|
||||
'faq_q4' => 'Payment secure?',
|
||||
'faq_a4' => 'Yes, via Lemon Squeezy – secure and GDPR-compliant.',
|
||||
'faq_a4' => 'Yes, via PayPal - secure and GDPR-compliant.',
|
||||
'final_cta' => 'Ready for your next event?',
|
||||
'contact_us' => 'Contact Us',
|
||||
'feature_live_slideshow' => 'Live Slideshow',
|
||||
@@ -64,12 +64,13 @@ return [
|
||||
'max_guests_label' => 'Max. guests',
|
||||
'gallery_days_label' => 'Gallery days',
|
||||
'feature_overview' => 'Feature overview',
|
||||
'order_hint' => 'Ready to launch instantly – secure Lemon Squeezy checkout, no hidden fees.',
|
||||
'order_hint' => 'Ready to launch instantly - secure PayPal checkout, no hidden fees.',
|
||||
'features_label' => 'Features',
|
||||
'breakdown_label' => 'At-a-glance',
|
||||
'limits_label' => 'Limits & Capacity',
|
||||
'lemonsqueezy_not_configured' => 'This package is not ready for Lemon Squeezy checkout. Please contact support.',
|
||||
'lemonsqueezy_checkout_failed' => 'We could not start the Lemon Squeezy checkout. Please try again later.',
|
||||
'lemonsqueezy_not_configured' => 'This package is not ready for PayPal checkout. Please contact support.',
|
||||
'lemonsqueezy_checkout_failed' => 'We could not start the PayPal checkout. Please try again later.',
|
||||
'paypal_checkout_failed' => 'We could not start the PayPal checkout. Please try again later.',
|
||||
'package_not_found' => 'This package is no longer available. Please choose another one.',
|
||||
],
|
||||
'nav' => [
|
||||
|
||||
Reference in New Issue
Block a user