Complete guide to implementing checkout flow with Crave API and Stripe
async function validateCart(locationId, cartId) {
try {
const response = await fetch(`${process.env.CRAVE_API_BASE_URL}/api/v1/locations/${locationId}/cart/${cartId}/validate-and-update`, {
method: 'PUT',
headers: {
'X-API-Key': process.env.CRAVE_API_KEY,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Cart validation failed');
}
const validation = await response.json();
return validation;
} catch (error) {
console.error('Cart validation error:', error);
throw error;
}
}
import { useState } from 'react';
export default function CustomerForm({ onSubmit }) {
const [customer, setCustomer] = useState({
name: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
zip: ''
}
});
const [errors, setErrors] = useState({});
const validateForm = () => {
const newErrors = {};
if (!customer.name.trim()) {
newErrors.name = 'Name is required';
}
if (!customer.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(customer.email)) {
newErrors.email = 'Email is invalid';
}
if (!customer.phone.trim()) {
newErrors.phone = 'Phone is required';
}
// Address validation for delivery orders
if (customer.fulfillmentType === 'delivery') {
if (!customer.address.street.trim()) {
newErrors.address = 'Street address is required';
}
if (!customer.address.city.trim()) {
newErrors.city = 'City is required';
}
if (!customer.address.zip.trim()) {
newErrors.zip = 'ZIP code is required';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit(customer);
}
};
return (
<form onSubmit={handleSubmit} className="customer-form">
<h3>Customer Information</h3>
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
type="text"
id="name"
value={customer.name}
onChange={(e) => setCustomer({...customer, name: e.target.value})}
className={errors.name ? 'error' : ''}
/>
{errors.name && <span className="error-message">{errors.name}</span>}
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
value={customer.email}
onChange={(e) => setCustomer({...customer, email: e.target.value})}
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
<div className="form-group">
<label htmlFor="phone">Phone *</label>
<input
type="tel"
id="phone"
value={customer.phone}
onChange={(e) => setCustomer({...customer, phone: e.target.value})}
className={errors.phone ? 'error' : ''}
/>
{errors.phone && <span className="error-message">{errors.phone}</span>}
</div>
<div className="form-group">
<label>
<input
type="radio"
name="fulfillmentType"
value="pickup"
checked={customer.fulfillmentType === 'pickup'}
onChange={(e) => setCustomer({...customer, fulfillmentType: e.target.value})}
/>
Pickup
</label>
<label>
<input
type="radio"
name="fulfillmentType"
value="delivery"
checked={customer.fulfillmentType === 'delivery'}
onChange={(e) => setCustomer({...customer, fulfillmentType: e.target.value})}
/>
Delivery
</label>
</div>
{customer.fulfillmentType === 'delivery' && (
<div className="address-section">
<h4>Delivery Address</h4>
<div className="form-group">
<label htmlFor="street">Street Address *</label>
<input
type="text"
id="street"
value={customer.address.street}
onChange={(e) => setCustomer({
...customer,
address: {...customer.address, street: e.target.value}
})}
className={errors.address ? 'error' : ''}
/>
{errors.address && <span className="error-message">{errors.address}</span>}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="city">City *</label>
<input
type="text"
id="city"
value={customer.address.city}
onChange={(e) => setCustomer({
...customer,
address: {...customer.address, city: e.target.value}
})}
className={errors.city ? 'error' : ''}
/>
{errors.city && <span className="error-message">{errors.city}</span>}
</div>
<div className="form-group">
<label htmlFor="state">State</label>
<select
id="state"
value={customer.address.state}
onChange={(e) => setCustomer({
...customer,
address: {...customer.address, state: e.target.value}
})}
>
<option value="">Select State</option>
<option value="CA">California</option>
<option value="NY">New York</option>
{/* Add more states */}
</select>
</div>
<div className="form-group">
<label htmlFor="zip">ZIP Code *</label>
<input
type="text"
id="zip"
value={customer.address.zip}
onChange={(e) => setCustomer({
...customer,
address: {...customer.address, zip: e.target.value}
})}
className={errors.zip ? 'error' : ''}
/>
{errors.zip && <span className="error-message">{errors.zip}</span>}
</div>
</div>
</div>
)}
<button type="submit" className="continue-button">
Continue to Payment
</button>
</form>
);
}
// pages/api/create-payment-intent.js
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { locationId, cartId, customer } = req.body;
try {
// First, get cart details
const cartResponse = await fetch(`https://api.cravejs.com/api/v1/locations/${locationId}/carts/${cartId}`, {
headers: {
'X-API-Key': process.env.CRAVE_API_KEY,
'Content-Type': 'application/json'
}
});
if (!cartResponse.ok) {
throw new Error('Failed to fetch cart');
}
const cart = await cartResponse.json();
// Create payment intent via Crave API
const paymentResponse = await fetch(`https://api.cravejs.com/api/v1/stripe/payment-intent?locationId=${locationId}&cartId=${cartId}`, {
method: 'GET',
headers: {
'X-API-Key': process.env.CRAVE_API_KEY,
'Content-Type': 'application/json'
}
});
if (!paymentResponse.ok) {
throw new Error('Failed to create payment intent');
}
const paymentData = await paymentResponse.json();
res.json({
clientSecret: paymentData.clientSecret,
total: cart.total
});
} catch (error) {
console.error('Payment intent creation failed:', error);
res.status(500).json({ error: 'Payment setup failed' });
}
}
import { useState, useEffect } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
export default function CheckoutForm({ cart, customer, onSuccess, onError }) {
const [clientSecret, setClientSecret] = useState('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (cart && customer) {
createPaymentIntent();
}
}, [cart, customer]);
const createPaymentIntent = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
locationId: cart.location_id,
cartId: cart.id,
customer
})
});
const data = await response.json();
if (data.clientSecret) {
setClientSecret(data.clientSecret);
} else {
throw new Error('Failed to setup payment');
}
} catch (error) {
onError(error.message);
} finally {
setIsLoading(false);
}
};
const options = {
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#007bff',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#dc3545',
fontFamily: 'system-ui, sans-serif',
spacingUnit: '4px',
borderRadius: '8px'
}
}
};
if (isLoading) {
return <div className="payment-loading">Setting up payment...</div>;
}
if (!clientSecret) {
return <div className="payment-error">Payment setup failed</div>;
}
return (
<Elements options={options} stripe={stripePromise}>
<PaymentForm onSuccess={onSuccess} onError={onError} />
</Elements>
);
}
function PaymentForm({ onSuccess, onError }) {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
setIsProcessing(true);
try {
const result = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/order-confirmation`
},
redirect: 'if_required'
});
if (result.error) {
onError(result.error.message);
} else {
onSuccess(result.paymentIntent);
}
} catch (error) {
onError(error.message);
} finally {
setIsProcessing(false);
}
};
return (
<form onSubmit={handleSubmit} className="payment-form">
<h3>Payment Information</h3>
<PaymentElement />
<button
type="submit"
disabled={!stripe || isProcessing}
className="pay-button"
>
{isProcessing ? 'Processing...' : 'Complete Order'}
</button>
</form>
);
}
import { useState } from 'react';
import CustomerForm from './CustomerForm';
import CheckoutForm from './CheckoutForm';
import OrderSummary from './OrderSummary';
export default function Checkout({ cart, onSuccess }) {
const [step, setStep] = useState('customer'); // 'customer', 'payment', 'success'
const [customer, setCustomer] = useState(null);
const [error, setError] = useState(null);
const handleCustomerSubmit = async (customerData) => {
try {
// Validate cart before proceeding
const validation = await validateCart(cart.location_id, cart.id);
if (!validation.valid) {
setError('Some items in your cart are no longer available');
return;
}
setCustomer(customerData);
setStep('payment');
} catch (error) {
setError('Failed to validate cart. Please try again.');
}
};
const handlePaymentSuccess = (paymentIntent) => {
onSuccess({
paymentIntent,
customer,
cart
});
setStep('success');
};
const handlePaymentError = (errorMessage) => {
setError(errorMessage);
};
const handleRetry = () => {
setError(null);
setStep('customer');
};
return (
<div className="checkout">
<div className="checkout-content">
<div className="checkout-main">
{step === 'customer' && (
<CustomerForm onSubmit={handleCustomerSubmit} />
)}
{step === 'payment' && (
<CheckoutForm
cart={cart}
customer={customer}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
/>
)}
{step === 'success' && (
<div className="checkout-success">
<h2>Order Placed Successfully!</h2>
<p>Your order has been received and is being processed.</p>
<p>You will receive a confirmation email shortly.</p>
</div>
)}
</div>
<div className="checkout-sidebar">
<OrderSummary cart={cart} />
</div>
</div>
{error && (
<div className="checkout-error">
<p>{error}</p>
<button onClick={handleRetry}>Try Again</button>
</div>
)}
</div>
);
}
export default function OrderSummary({ cart }) {
if (!cart) return null;
return (
<div className="order-summary">
<h3>Order Summary</h3>
<div className="cart-items">
{cart.items?.map(item => (
<div key={item.id} className="cart-item">
<div className="item-info">
<h4>{item.name}</h4>
<p className="quantity">Qty: {item.quantity}</p>
{item.modifiers && item.modifiers.length > 0 && (
<div className="modifiers">
{item.modifiers.map(mod => (
<span key={mod.id} className="modifier">
{mod.name}
</span>
))}
</div>
)}
</div>
<div className="item-price">
${((item.price * item.quantity) / 100).toFixed(2)}
</div>
</div>
))}
</div>
<div className="order-totals">
<div className="total-line">
<span>Subtotal:</span>
<span>${(cart.subtotal / 100).toFixed(2)}</span>
</div>
{cart.tax > 0 && (
<div className="total-line">
<span>Tax:</span>
<span>${(cart.tax / 100).toFixed(2)}</span>
</div>
)}
{cart.tip > 0 && (
<div className="total-line">
<span>Tip:</span>
<span>${(cart.tip / 100).toFixed(2)}</span>
</div>
)}
{cart.delivery_fee > 0 && (
<div className="total-line">
<span>Delivery Fee:</span>
<span>${(cart.delivery_fee / 100).toFixed(2)}</span>
</div>
)}
<div className="total-line total">
<span>Total:</span>
<span>${(cart.total / 100).toFixed(2)}</span>
</div>
</div>
</div>
);
}
.checkout {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.checkout-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2rem;
}
.checkout-main {
background: white;
padding: 2rem;
border-radius: 8px;
border: 1px solid #ddd;
}
.checkout-sidebar {
background: #f8f9fa;
padding: 2rem;
border-radius: 8px;
height: fit-content;
position: sticky;
top: 2rem;
}
.customer-form .form-group {
margin-bottom: 1rem;
}
.customer-form label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.customer-form input,
.customer-form select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.customer-form input.error {
border-color: #dc3545;
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.continue-button,
.pay-button {
width: 100%;
background: #007bff;
color: white;
border: none;
padding: 1rem;
font-size: 1.1rem;
border-radius: 8px;
cursor: pointer;
margin-top: 1rem;
}
.continue-button:hover,
.pay-button:hover {
background: #0056b3;
}
.pay-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.order-summary {
background: white;
padding: 1.5rem;
border-radius: 8px;
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem 0;
border-bottom: 1px solid #eee;
}
.modifiers {
margin-top: 0.5rem;
}
.modifier {
display: inline-block;
background: #e9ecef;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
margin-right: 0.5rem;
}
.order-totals {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.total-line {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.total-line.total {
font-weight: bold;
font-size: 1.1rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #ddd;
}
.checkout-error {
background: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
text-align: center;
}
.checkout-success {
text-align: center;
padding: 2rem;
}
.checkout-success h2 {
color: #28a745;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.checkout-content {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
}
function handleCheckoutError(error) {
switch (error.code) {
case 'card_declined':
return 'Your card was declined. Please try a different payment method.';
case 'insufficient_funds':
return 'Insufficient funds. Please try a different card.';
case 'expired_card':
return 'Your card has expired. Please use a different card.';
case 'incorrect_cvc':
return 'Your card security code is incorrect.';
case 'processing_error':
return 'There was an error processing your payment. Please try again.';
case 'cart_validation_failed':
return 'Some items in your cart are no longer available.';
default:
return 'An error occurred during checkout. Please try again.';
}
}