chore: initial import
This commit is contained in:
336
frontend/pages/cart.js
Normal file
336
frontend/pages/cart.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
|
||||
import SiteLayout from '../components/SiteLayout';
|
||||
import { submitOrder } from '../lib/api';
|
||||
import { useCart } from '../context/CartContext';
|
||||
|
||||
const INITIAL_CUSTOMER = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
fulfillment_method: 'pickup',
|
||||
scheduled_date: '',
|
||||
scheduled_time_slot: '',
|
||||
delivery_address_line1: '',
|
||||
delivery_city: '',
|
||||
delivery_state: 'WI',
|
||||
delivery_postal_code: '',
|
||||
override_code: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
const { items, updateQuantity, removeItem, clearCart, subtotal } = useCart();
|
||||
const [customer, setCustomer] = useState(INITIAL_CUSTOMER);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState(null);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
const showNotice = (message, variant = 'info') => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
setNotice({ message, variant });
|
||||
timeoutRef.current = setTimeout(() => setNotice(null), 4000);
|
||||
};
|
||||
|
||||
const handleQuantity = (id, delta) => {
|
||||
const entry = items.find((item) => item.id === id);
|
||||
if (!entry) return;
|
||||
const limit = Number.isFinite(entry.limit) ? entry.limit : Infinity;
|
||||
const next = Math.max(0, Math.min(entry.quantity + delta, limit));
|
||||
updateQuantity(id, next);
|
||||
};
|
||||
|
||||
const deliveryFee = customer.fulfillment_method === 'delivery' ? 5 : 0;
|
||||
const total = subtotal + deliveryFee;
|
||||
const hasCartItems = items.length > 0;
|
||||
|
||||
const updateCustomerField = (field, value) => {
|
||||
setCustomer((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!hasCartItems) {
|
||||
showNotice('Add at least one item to your cart before checking out.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customer.name.trim() || !customer.email.trim()) {
|
||||
showNotice('Name and email are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.fulfillment_method === 'delivery') {
|
||||
if (!customer.delivery_address_line1.trim() || !customer.delivery_city.trim() || !customer.delivery_postal_code.trim()) {
|
||||
showNotice('Delivery address, city, and postal code are required for delivery orders.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (items.some((item) => !item.id)) {
|
||||
showNotice('Cart contains placeholder items. Add products from the live menu once available.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
customer_name: customer.name.trim(),
|
||||
customer_email: customer.email.trim(),
|
||||
customer_phone: customer.phone.trim(),
|
||||
fulfillment_method: customer.fulfillment_method,
|
||||
scheduled_date: customer.scheduled_date || null,
|
||||
scheduled_time_slot: customer.scheduled_time_slot,
|
||||
delivery_address_line1:
|
||||
customer.fulfillment_method === 'delivery' ? customer.delivery_address_line1.trim() : '',
|
||||
delivery_address_line2: '',
|
||||
delivery_city: customer.fulfillment_method === 'delivery' ? customer.delivery_city.trim() : '',
|
||||
delivery_state: customer.fulfillment_method === 'delivery' ? customer.delivery_state.trim() : '',
|
||||
delivery_postal_code:
|
||||
customer.fulfillment_method === 'delivery' ? customer.delivery_postal_code.trim() : '',
|
||||
delivery_override_code: customer.override_code.trim() || null,
|
||||
notes: customer.notes,
|
||||
items: items.map((item) => ({ product: item.id, quantity: item.quantity })),
|
||||
};
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const order = await submitOrder(payload);
|
||||
setConfirmation(order.id);
|
||||
clearCart();
|
||||
setCustomer(INITIAL_CUSTOMER);
|
||||
showNotice('Order received! Check your email for confirmation.', 'success');
|
||||
} catch (error) {
|
||||
showNotice('We were unable to place your order. Please try again.', 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SiteLayout>
|
||||
<Head>
|
||||
<title>Cart · Full Moon Bakehouse</title>
|
||||
</Head>
|
||||
|
||||
<div className="wrapper cart-page">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Your cart</h2>
|
||||
<p>Add or adjust items, then submit your preorder request.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? <div className={`flash ${notice.variant}`}>{notice.message}</div> : null}
|
||||
|
||||
<div className="cart-layout">
|
||||
<div className="cart-card">
|
||||
<div className="cart-header">
|
||||
<h3>Items</h3>
|
||||
{hasCartItems ? (
|
||||
<button className="btn-small danger" type="button" onClick={clearCart}>
|
||||
Clear cart
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasCartItems ? (
|
||||
<div className="cart-list">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="cart-item">
|
||||
<div className="cart-item__row">
|
||||
<strong>{item.name}</strong>
|
||||
<span>${(item.price * item.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="cart-item__controls">
|
||||
<button className="stepper" type="button" onClick={() => handleQuantity(item.id, -1)}>
|
||||
–
|
||||
</button>
|
||||
<span>{item.quantity}</span>
|
||||
<button className="stepper" type="button" onClick={() => handleQuantity(item.id, 1)}>
|
||||
+
|
||||
</button>
|
||||
<button className="btn-small danger" type="button" onClick={() => removeItem(item.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{Number.isFinite(item.limit) ? (
|
||||
<small className="cart-note">Max {item.limit} per order.</small>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cart-empty">Your cart is empty. Browse the menu to get started.</div>
|
||||
)}
|
||||
|
||||
<div className="cart-summary">
|
||||
<div className="cart-item__row">
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="cart-item__row" style={{ fontWeight: 500 }}>
|
||||
<span>Delivery fee</span>
|
||||
<span>{customer.fulfillment_method === 'delivery' ? '$5.00' : '$0.00'}</span>
|
||||
</div>
|
||||
<div className="cart-item__row" style={{ fontSize: '1.1rem' }}>
|
||||
<span>Total</span>
|
||||
<span>${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="cart-card checkout-form" onSubmit={handleSubmit}>
|
||||
<h3>Checkout</h3>
|
||||
{confirmation ? (
|
||||
<div className="notice success">Order #{confirmation} received!</div>
|
||||
) : null}
|
||||
|
||||
<div className="form-grid two-column">
|
||||
<label className="form-field">
|
||||
Name *
|
||||
<input
|
||||
className="input"
|
||||
value={customer.name}
|
||||
onChange={(event) => updateCustomerField('name', event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Email *
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
value={customer.email}
|
||||
onChange={(event) => updateCustomerField('email', event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Phone
|
||||
<input
|
||||
className="input"
|
||||
value={customer.phone}
|
||||
onChange={(event) => updateCustomerField('phone', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Preferred pickup date
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={customer.scheduled_date}
|
||||
onChange={(event) => updateCustomerField('scheduled_date', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Time window
|
||||
<input
|
||||
className="input"
|
||||
placeholder="e.g., Thu 5:30-5:45pm"
|
||||
value={customer.scheduled_time_slot}
|
||||
onChange={(event) => updateCustomerField('scheduled_time_slot', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="radio-group">
|
||||
<span>Fulfillment method</span>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="fulfillment_method"
|
||||
value="pickup"
|
||||
checked={customer.fulfillment_method === 'pickup'}
|
||||
onChange={(event) => updateCustomerField('fulfillment_method', event.target.value)}
|
||||
/>{' '}
|
||||
Pickup (free)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="fulfillment_method"
|
||||
value="delivery"
|
||||
checked={customer.fulfillment_method === 'delivery'}
|
||||
onChange={(event) => updateCustomerField('fulfillment_method', event.target.value)}
|
||||
/>{' '}
|
||||
Delivery in Eau Claire or Altoona ($5)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{customer.fulfillment_method === 'delivery' ? (
|
||||
<div className="form-grid two-column">
|
||||
<label className="form-field">
|
||||
Address line 1 *
|
||||
<input
|
||||
className="input"
|
||||
value={customer.delivery_address_line1}
|
||||
onChange={(event) =>
|
||||
updateCustomerField('delivery_address_line1', event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
City *
|
||||
<input
|
||||
className="input"
|
||||
value={customer.delivery_city}
|
||||
onChange={(event) => updateCustomerField('delivery_city', event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
State
|
||||
<input
|
||||
className="input"
|
||||
value={customer.delivery_state}
|
||||
onChange={(event) => updateCustomerField('delivery_state', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Postal code *
|
||||
<input
|
||||
className="input"
|
||||
value={customer.delivery_postal_code}
|
||||
onChange={(event) =>
|
||||
updateCustomerField('delivery_postal_code', event.target.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="form-field">
|
||||
Secret friend code
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Optional override code"
|
||||
value={customer.override_code}
|
||||
onChange={(event) => updateCustomerField('override_code', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
Notes
|
||||
<textarea
|
||||
rows={3}
|
||||
className="textarea"
|
||||
placeholder="Allergies, delivery notes, or special requests."
|
||||
value={customer.notes}
|
||||
onChange={(event) => updateCustomerField('notes', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={submitting || !hasCartItems}>
|
||||
{submitting ? 'Submitting…' : 'Place order'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</SiteLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user