Overview

This guide walks you through implementing a complete checkout flow using the Crave API and Stripe. The checkout process involves cart validation, payment processing, and order creation.

Checkout Flow

The storefront checkout flow follows these steps:
  1. Cart Validation - Ensure cart is valid and available
  2. Customer Information - Collect customer details for order
  3. Payment Intent - Create Stripe payment intent via Crave API
  4. Payment Processing - Handle Stripe payment UI
  5. Order Creation - Crave automatically creates order via webhook after successful payment
Important: Storefronts don’t create orders directly. Orders are created automatically by Crave’s backend when payments succeed.

Step 1: Cart Validation

Before checkout, validate the cart to ensure all items are still available:
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;
  }
}

Step 2: Customer Information

Collect required customer information:
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>
  );
}

Step 3: Payment Intent Creation

Create a payment intent through your API:
// 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' });
  }
}

Step 4: Payment Processing

Use Stripe Elements for secure payment processing:
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>
  );
}

Step 5: Complete Checkout Component

Here’s how to put it all together:
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>
  );
}

Order Summary Component

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>
  );
}

Styling

.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;
  }
}

Error Handling

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.';
  }
}

Next Steps

After successful payment:
  1. Order Confirmation - Show payment success and estimated time
  2. Webhook Processing - Crave’s backend creates the order automatically
  3. Merchant Notification - Order appears in merchant dashboard
  4. Customer Receipt - Stripe handles payment receipts
Note: Storefronts don’t handle order tracking or customer accounts - these are merchant-side features.