Files
yslootahrobotics/src/components/CheckoutOverlay.tsx
T
Najjar\NajjarV02andClaude Opus 5.5 81358b0815
CI/CD / test-and-build (pull_request) Canceled after 0s
CI/CD / deploy (pull_request) Canceled after 0s
fix(checkout): skip loadStripe when no publishable key is set
loadStripe(undefined) threw "Expected publishable key to be of type
string" on every page that imports CheckoutOverlay. Production sets the
key, so live behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:18:46 +04:00

232 lines
8.4 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
import { ShippingStep } from './checkout/ShippingStep';
import { PaymentStep } from './checkout/PaymentStep';
import { ReviewStep } from './checkout/ReviewStep';
import { ConfirmationStep } from './checkout/ConfirmationStep';
// Without a publishable key (local builds), loadStripe throws on every page that
// imports this module. Elements accepts null and the payment step stays empty.
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = publishableKey ? loadStripe(publishableKey) : null;
const STEPS: { id: CheckoutStep; label: string }[] = [
{ id: 'shipping', label: 'Shipping' },
{ id: 'payment', label: 'Payment' },
{ id: 'review', label: 'Review' },
];
function getStepIndex(step: CheckoutStep): number {
return STEPS.findIndex((s) => s.id === step);
}
export function CheckoutOverlay() {
const step = useOrderStore((s) => s.step);
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
const paymentStatus = useOrderStore((s) => s.payment.status);
const paymentError = useOrderStore((s) => s.payment.errorMessage);
const [isCreatingIntent, setIsCreatingIntent] = useState(false);
const handleClose = useCallback(() => {
orderStore.getState().resetOrder();
}, []);
const handleBack = useCallback(() => {
const currentIndex = getStepIndex(step);
if (currentIndex > 0) {
orderStore.getState().setStep(STEPS[currentIndex - 1].id);
} else {
orderStore.getState().setStep('config');
}
}, [step]);
// Create payment intent when entering the payment step (only once)
useEffect(() => {
if (step === 'payment' && !clientSecret && !isCreatingIntent && paymentStatus !== 'failed') {
setIsCreatingIntent(true);
orderStore.getState().createPaymentIntent().finally(() => {
setIsCreatingIntent(false);
});
}
}, [step, clientSecret, isCreatingIntent, paymentStatus]);
// Close on Escape key
useEffect(() => {
if (step === 'config') return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [step, handleClose]);
if (step === 'config') return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Checkout"
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(12px)',
}}
>
<div style={{
width: '100%',
maxWidth: '560px',
maxHeight: '90vh',
overflowY: 'auto',
margin: '1rem',
background: 'rgba(255, 255, 255, 0.95)',
border: '1px solid rgba(153, 153, 153, 0.08)',
borderRadius: '1rem',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(153, 153, 153, 0.1)',
}}>
{/* Header with progress */}
{step !== 'confirmed' && (
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid rgba(153, 153, 153, 0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<button
onClick={handleBack}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#a8a8a8',
cursor: 'pointer',
fontSize: '0.75rem',
transition: 'all 0.2s ease',
}}
>
Back
</button>
<h2 style={{ fontSize: '0.9rem', fontWeight: 600, color: '#0b0b0b', margin: 0 }}>
Checkout
</h2>
<button
onClick={handleClose}
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
border: '1px solid rgba(0, 0, 0, 0.08)',
background: 'transparent',
color: '#a8a8a8',
cursor: 'pointer',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close checkout"
>
x
</button>
</div>
{/* Progress bar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{STEPS.map((s, i) => {
const currentIdx = getStepIndex(step);
const isActive = i === currentIdx;
const isComplete = i < currentIdx;
return (
<div key={s.id} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div style={{
height: '3px',
borderRadius: '2px',
background: isComplete
? '#999999'
: isActive
? 'linear-gradient(90deg, #999999, rgba(153, 153, 153, 0.2))'
: 'rgba(0, 0, 0, 0.06)',
transition: 'all 0.3s ease',
}} />
<span style={{
fontSize: '0.65rem',
color: isActive ? '#888888' : isComplete ? '#999999' : '#888888',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
}}>
{s.label}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Step Content */}
<div style={{ padding: '1.5rem' }}>
{step === 'shipping' && <ShippingStep />}
{(step === 'payment' || step === 'review') && clientSecret ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#888888',
borderRadius: '0.375rem',
fontFamily: 'inherit',
},
},
}}
>
{/* Keep PaymentElement mounted (hidden) during review so confirmPayment works */}
<div style={{ display: step === 'payment' ? 'block' : 'none' }}>
<PaymentStep />
</div>
{step === 'review' && <ReviewStep />}
</Elements>
) : (step === 'payment' || step === 'review') && !clientSecret ? (
<div style={{ textAlign: 'center', padding: '2rem', fontSize: '0.85rem' }}>
{paymentStatus === 'failed' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ color: '#dc2626' }}>{paymentError || 'Failed to initialize payment'}</div>
<button
onClick={() => {
orderStore.getState().setPayment({ status: 'idle', errorMessage: '' });
}}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid rgba(153, 153, 153, 0.5)',
background: 'rgba(153, 153, 153, 0.08)',
color: '#888888',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
}}
>
Retry
</button>
</div>
) : (
<span style={{ color: '#a8a8a8' }}>Initializing secure payment...</span>
)}
</div>
) : null}
{step === 'confirmed' && <ConfirmationStep />}
</div>
</div>
</div>
);
}