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>
232 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|