Updated checkout to wait for backend confirmation before advancing, added a “Processing payment…” state with retry/ refresh fallback, and now use Paddle totals/currency for purchase records + confirmation emails (with new email translations).
This commit is contained in:
@@ -85,15 +85,6 @@ const WizardBody: React.FC<{
|
||||
const hasMountedRef = useRef(false);
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const isFreeSelected = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const priceValue = Number(selectedPackage.price);
|
||||
return Number.isFinite(priceValue) && priceValue <= 0;
|
||||
}, [selectedPackage]);
|
||||
|
||||
const stepConfig = useMemo(() =>
|
||||
baseStepConfig.map(step => ({
|
||||
id: step.id,
|
||||
@@ -159,11 +150,11 @@ const WizardBody: React.FC<{
|
||||
}
|
||||
|
||||
if (currentStep === 'payment') {
|
||||
return isFreeSelected || paymentCompleted;
|
||||
return paymentCompleted;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]);
|
||||
|
||||
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
|
||||
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
|
||||
|
||||
@@ -206,6 +206,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
|
||||
<LoginForm
|
||||
locale={locale}
|
||||
onSuccess={handleLoginSuccess}
|
||||
packageId={selectedPackage?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -158,6 +158,12 @@ export const PaymentStep: React.FC = () => {
|
||||
const RateLimitHelper = useRateLimitHelper('coupon');
|
||||
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(null);
|
||||
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
|
||||
const [checkoutSessionId, setCheckoutSessionId] = useState<string | null>(null);
|
||||
const [freeActivationBusy, setFreeActivationBusy] = useState(false);
|
||||
const [awaitingConfirmation, setAwaitingConfirmation] = useState(false);
|
||||
const [confirmationElapsedMs, setConfirmationElapsedMs] = useState(0);
|
||||
const confirmationTimerRef = useRef<number | null>(null);
|
||||
const statusCheckRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const paddleLocale = useMemo(() => {
|
||||
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
|
||||
@@ -253,13 +259,57 @@ export const PaymentStep: React.FC = () => {
|
||||
}, [couponCode]);
|
||||
|
||||
const handleFreeActivation = async () => {
|
||||
if (!selectedPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
|
||||
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentCompleted(true);
|
||||
nextStep();
|
||||
setConsentError(null);
|
||||
setFreeActivationBusy(true);
|
||||
setAwaitingConfirmation(false);
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
const response = await fetch('/checkout/free-activate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
accepted_terms: acceptedTerms,
|
||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
||||
locale: paddleLocale,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paddle_error');
|
||||
setConsentError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckoutSessionId(payload?.checkout_session_id ?? null);
|
||||
setPaymentCompleted(true);
|
||||
nextStep();
|
||||
} catch (error) {
|
||||
console.error('Failed to activate free package', error);
|
||||
const fallbackMessage = t('checkout.payment_step.paddle_error');
|
||||
setConsentError(fallbackMessage);
|
||||
toast.error(fallbackMessage);
|
||||
} finally {
|
||||
setFreeActivationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPaddleCheckout = async () => {
|
||||
@@ -282,6 +332,9 @@ export const PaymentStep: React.FC = () => {
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.paddle_preparing'));
|
||||
setInlineActive(false);
|
||||
setCheckoutSessionId(null);
|
||||
setAwaitingConfirmation(false);
|
||||
setConfirmationElapsedMs(0);
|
||||
|
||||
try {
|
||||
const inlineSupported = initialised && !!paddleConfig?.client_token;
|
||||
@@ -297,7 +350,65 @@ export const PaymentStep: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (inlineSupported) {
|
||||
const response = await fetch('/paddle/create-checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
locale: paddleLocale,
|
||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||
accepted_terms: acceptedTerms,
|
||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
||||
inline: inlineSupported,
|
||||
}),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
||||
}
|
||||
|
||||
let data: { checkout_url?: string; message?: string } | null = 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;
|
||||
}
|
||||
|
||||
if (data && typeof (data as { checkout_session_id?: string }).checkout_session_id === 'string') {
|
||||
setCheckoutSessionId((data as { checkout_session_id?: string }).checkout_session_id ?? 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.';
|
||||
if (response.ok && data && (data as { mode?: string }).mode === 'inline') {
|
||||
checkoutUrl = null;
|
||||
} else {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && (data as { mode?: string }).mode === 'inline') {
|
||||
const paddle = paddleRef.current;
|
||||
|
||||
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
|
||||
@@ -305,31 +416,23 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
|
||||
const inlinePayload: Record<string, unknown> = {
|
||||
items: [
|
||||
{
|
||||
priceId: selectedPackage.paddle_price_id,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
items: (data as { items?: unknown[] }).items ?? [],
|
||||
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,
|
||||
accepted_terms: acceptedTerms ? '1' : '0',
|
||||
accepted_waiver: requiresImmediateWaiver && acceptedWaiver ? '1' : '0',
|
||||
},
|
||||
};
|
||||
locale: paddleLocale,
|
||||
},
|
||||
};
|
||||
|
||||
const customerEmail = authUser?.email ?? null;
|
||||
if (customerEmail) {
|
||||
inlinePayload.customer = { email: customerEmail };
|
||||
if ((data as { custom_data?: Record<string, unknown> }).custom_data) {
|
||||
inlinePayload.customData = (data as { custom_data?: Record<string, unknown> }).custom_data;
|
||||
}
|
||||
|
||||
if ((data as { customer?: Record<string, unknown> }).customer) {
|
||||
inlinePayload.customer = (data as { customer?: Record<string, unknown> }).customer;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -356,54 +459,6 @@ export const PaymentStep: React.FC = () => {
|
||||
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,
|
||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||
accepted_terms: acceptedTerms,
|
||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
||||
}),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
||||
}
|
||||
|
||||
let data: { checkout_url?: string; message?: string } | null = 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');
|
||||
@@ -434,14 +489,15 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.completed') {
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.processing_confirmation'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(true);
|
||||
setAwaitingConfirmation(true);
|
||||
setPaymentCompleted(false);
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.closed') {
|
||||
if (event.name === 'checkout.closed' && !awaitingConfirmation) {
|
||||
setStatus('idle');
|
||||
setMessage('');
|
||||
setInlineActive(false);
|
||||
@@ -452,6 +508,7 @@ export const PaymentStep: React.FC = () => {
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setInlineActive(false);
|
||||
setAwaitingConfirmation(false);
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
};
|
||||
@@ -502,12 +559,130 @@ export const PaymentStep: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
||||
}, [awaitingConfirmation, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setPaymentCompleted(false);
|
||||
setCheckoutSessionId(null);
|
||||
setStatus('idle');
|
||||
setMessage('');
|
||||
setInlineActive(false);
|
||||
setAwaitingConfirmation(false);
|
||||
setConfirmationElapsedMs(0);
|
||||
}, [selectedPackage?.id, setPaymentCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!awaitingConfirmation || typeof window === 'undefined') {
|
||||
if (confirmationTimerRef.current) {
|
||||
window.clearInterval(confirmationTimerRef.current);
|
||||
confirmationTimerRef.current = null;
|
||||
}
|
||||
setConfirmationElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
confirmationTimerRef.current = window.setInterval(() => {
|
||||
setConfirmationElapsedMs(Date.now() - startedAt);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (confirmationTimerRef.current) {
|
||||
window.clearInterval(confirmationTimerRef.current);
|
||||
confirmationTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [awaitingConfirmation]);
|
||||
|
||||
const checkSessionStatus = useCallback(async (): Promise<boolean> => {
|
||||
if (!checkoutSessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/checkout/session/${checkoutSessionId}/status`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload?.status === 'completed') {
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.status_success'));
|
||||
setInlineActive(false);
|
||||
setAwaitingConfirmation(false);
|
||||
setPaymentCompleted(true);
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
nextStep();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (payload?.status === 'failed' || payload?.status === 'cancelled') {
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setAwaitingConfirmation(false);
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [checkoutSessionId, nextStep, setPaymentCompleted, t]);
|
||||
|
||||
useEffect(() => {
|
||||
statusCheckRef.current = () => {
|
||||
void checkSessionStatus();
|
||||
};
|
||||
}, [checkSessionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutSessionId || paymentCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const schedulePoll = () => {
|
||||
if (cancelled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
void pollStatus();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = await checkSessionStatus();
|
||||
|
||||
if (!completed) {
|
||||
schedulePoll();
|
||||
}
|
||||
};
|
||||
|
||||
void pollStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId && typeof window !== 'undefined') {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [checkSessionStatus, checkoutSessionId, paymentCompleted]);
|
||||
|
||||
const handleCouponSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -553,6 +728,20 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
}, [paddleLocale, t, withdrawalHtml, withdrawalLoading]);
|
||||
|
||||
const showManualActions = awaitingConfirmation && confirmationElapsedMs >= 30000;
|
||||
|
||||
const handleStatusRetry = useCallback(() => {
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.processing_confirmation'));
|
||||
statusCheckRef.current?.();
|
||||
}, [t]);
|
||||
|
||||
const handlePageRefresh = useCallback(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!selectedPackage) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
@@ -569,8 +758,78 @@ export const PaymentStep: React.FC = () => {
|
||||
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
|
||||
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="checkout-terms-free"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) => {
|
||||
setAcceptedTerms(Boolean(checked));
|
||||
if (consentError) {
|
||||
setConsentError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Label htmlFor="checkout-terms-free" className="cursor-pointer">
|
||||
{t('checkout.legal.checkbox_terms_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('checkout.legal.legal_links_intro')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openWithdrawalModal();
|
||||
}}
|
||||
>
|
||||
{t('checkout.legal.open_withdrawal')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresImmediateWaiver && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="checkout-waiver-free"
|
||||
checked={acceptedWaiver}
|
||||
onCheckedChange={(checked) => {
|
||||
setAcceptedWaiver(Boolean(checked));
|
||||
if (consentError) {
|
||||
setConsentError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Label htmlFor="checkout-waiver-free" className="cursor-pointer">
|
||||
{t('checkout.legal.checkbox_digital_content_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('checkout.legal.hint_subscription_withdrawal')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{consentError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span>{consentError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" onClick={handleFreeActivation}>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleFreeActivation}
|
||||
disabled={freeActivationBusy || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
|
||||
>
|
||||
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('checkout.payment_step.activate_package')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -578,6 +837,34 @@ export const PaymentStep: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (awaitingConfirmation) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<LoaderCircle className="h-6 w-6 animate-spin text-primary" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">{t('checkout.payment_step.processing_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.processing_body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showManualActions && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.processing_manual_hint')}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button type="button" variant="outline" onClick={handleStatusRetry}>
|
||||
{t('checkout.payment_step.processing_retry')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handlePageRefresh}>
|
||||
{t('checkout.payment_step.processing_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user