import { mdiArrowRight, mdiBasketOutline, mdiCarrot, mdiCashFast, mdiCheckCircleOutline, mdiClockOutline, mdiLeaf, mdiMapMarkerOutline, mdiMinus, mdiPlus, mdiSprout, mdiStorefrontOutline, mdiTruckFastOutline, } from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; import React, { ReactElement, useEffect, useMemo, useState } from 'react'; import axios from 'axios'; import BaseButton from '../components/BaseButton'; import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; import LoadingSpinner from '../components/LoadingSpinner'; import SectionMain from '../components/SectionMain'; import LayoutAuthenticated from '../layouts/Authenticated'; import { getPageTitle } from '../config'; import { hasPermission } from '../helpers/userPermissions'; import { useAppSelector } from '../stores/hooks'; type Category = { id: string; name: string; }; type Product = { id: string; name: string; short_description?: string | null; description?: string | null; unit?: string | null; unit_size?: number | string | null; price?: number | string | null; compare_at_price?: number | string | null; tax_rate?: number | string | null; is_taxable?: boolean; stock_quantity?: number | null; is_active?: boolean; is_featured?: boolean; category?: Category | null; }; type DeliverySlot = { id: string; name: string; slot_type: 'delivery' | 'pickup'; starts_at?: string | null; ends_at?: string | null; capacity?: number | null; reserved_count?: number | null; notes?: string | null; is_active?: boolean; }; type Address = { id: string; label?: string | null; recipient_name?: string | null; phone?: string | null; line1?: string | null; line2?: string | null; city?: string | null; state?: string | null; postal_code?: string | null; country?: string | null; }; type Order = { id: string; order_number?: string | null; status?: string | null; fulfillment_method?: 'delivery' | 'pickup' | null; total_amount?: number | string | null; placed_at?: string | null; delivery_slot?: DeliverySlot | null; delivery_address?: Address | null; }; type CheckoutOrder = Order & { order_items_order?: Array<{ id: string; product_name?: string | null; quantity?: number | null; line_total?: number | string | null; }>; }; type NewAddressForm = { label: string; recipient_name: string; phone: string; line1: string; line2: string; city: string; state: string; postal_code: string; country: string; }; const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }); const formatCurrency = (value: number | string | null | undefined) => { const parsed = Number(value || 0); return currencyFormatter.format(Number.isNaN(parsed) ? 0 : parsed); }; const formatSlotWindow = (slot: DeliverySlot) => { if (!slot.starts_at) { return 'Time to be confirmed'; } const start = new Date(slot.starts_at); const end = slot.ends_at ? new Date(slot.ends_at) : null; const base = start.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', }); const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', }); if (!end) { return `${base} · ${time}`; } return `${base} · ${time}–${end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', })}`; }; const formatAddress = (address: Address) => { return [ address.line1, address.line2, [address.city, address.state].filter(Boolean).join(', '), address.postal_code, address.country, ] .filter(Boolean) .join(' · '); }; const initialAddressForm = { label: 'Fresh delivery', recipient_name: '', phone: '', line1: '', line2: '', city: '', state: '', postal_code: '', country: 'USA', }; const StorefrontPage = () => { const { currentUser } = useAppSelector((state) => state.auth); const corners = useAppSelector((state) => state.style.corners); const focusRingColor = useAppSelector((state) => state.style.focusRingColor); const textSecondary = useAppSelector((state) => state.style.textSecondary); const [products, setProducts] = useState([]); const [deliverySlots, setDeliverySlots] = useState([]); const [addresses, setAddresses] = useState([]); const [recentOrders, setRecentOrders] = useState([]); const [cart, setCart] = useState>({}); const [search, setSearch] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); const [fulfillmentMethod, setFulfillmentMethod] = useState<'delivery' | 'pickup'>('delivery'); const [selectedSlotId, setSelectedSlotId] = useState(''); const [selectedAddressId, setSelectedAddressId] = useState('new'); const [customerNote, setCustomerNote] = useState(''); const [newAddress, setNewAddress] = useState(initialAddressForm); const [loading, setLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [loadError, setLoadError] = useState(''); const [submitError, setSubmitError] = useState(''); const [successOrder, setSuccessOrder] = useState(null); const hasReadProducts = hasPermission(currentUser, 'READ_PRODUCTS'); const hasReadOrders = hasPermission(currentUser, 'READ_ORDERS'); const hasReadAddresses = hasPermission(currentUser, 'READ_ADDRESSES'); const hasReadSlots = hasPermission(currentUser, 'READ_DELIVERY_SLOTS'); const canCheckout = hasPermission(currentUser, 'CREATE_ORDERS'); useEffect(() => { if (!currentUser) { return; } setNewAddress((previous) => ({ ...previous, recipient_name: previous.recipient_name || [currentUser.firstName, currentUser.lastName].filter(Boolean).join(' '), phone: previous.phone || currentUser.phoneNumber || '', })); }, [currentUser]); const loadStorefront = React.useCallback(async () => { if (!currentUser?.id || !hasReadProducts) { setLoading(false); return; } setLoading(true); setLoadError(''); try { const requests = [ axios.get('/products', { params: { limit: 24, page: 0 } }), hasReadSlots ? axios.get('/delivery_slots', { params: { limit: 100, page: 0 } }) : Promise.resolve({ data: { rows: [] } }), hasReadAddresses ? axios.get('/addresses', { params: { limit: 100, page: 0, user: currentUser.id } }) : Promise.resolve({ data: { rows: [] } }), hasReadOrders ? axios.get('/orders', { params: { limit: 4, page: 0, user: currentUser.id } }) : Promise.resolve({ data: { rows: [] } }), ]; const [productsResponse, slotsResponse, addressesResponse, ordersResponse] = await Promise.all(requests); setProducts(Array.isArray(productsResponse.data?.rows) ? productsResponse.data.rows : []); setDeliverySlots( (Array.isArray(slotsResponse.data?.rows) ? slotsResponse.data.rows : []).filter((slot: DeliverySlot) => slot.is_active !== false), ); const fetchedAddresses = Array.isArray(addressesResponse.data?.rows) ? addressesResponse.data.rows : []; setAddresses(fetchedAddresses); setSelectedAddressId((previous) => (previous === 'new' ? 'new' : previous || (fetchedAddresses[0]?.id || 'new'))); setRecentOrders(Array.isArray(ordersResponse.data?.rows) ? ordersResponse.data.rows : []); } catch (error) { if (axios.isAxiosError(error)) { setLoadError(error.response?.data || error.message || 'We could not load the storefront.'); } else { setLoadError('We could not load the storefront.'); } } finally { setLoading(false); } }, [currentUser?.id, hasReadAddresses, hasReadOrders, hasReadProducts, hasReadSlots]); useEffect(() => { loadStorefront(); }, [loadStorefront]); const categories = useMemo(() => { const seen = new Map(); products.forEach((product) => { if (product.category?.id && product.category?.name) { seen.set(product.category.id, product.category); } }); return Array.from(seen.values()).sort((left, right) => left.name.localeCompare(right.name)); }, [products]); const filteredProducts = useMemo(() => { const needle = search.trim().toLowerCase(); return products .filter((product) => product.is_active !== false) .filter((product) => { if (selectedCategory === 'all') { return true; } return product.category?.id === selectedCategory; }) .filter((product) => { if (!needle) { return true; } return [product.name, product.short_description, product.description, product.category?.name] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(needle)); }) .sort((left, right) => Number(Boolean(right.is_featured)) - Number(Boolean(left.is_featured))); }, [products, search, selectedCategory]); const selectedSlots = useMemo(() => { return deliverySlots.filter((slot) => slot.slot_type === fulfillmentMethod); }, [deliverySlots, fulfillmentMethod]); useEffect(() => { if (!selectedSlots.length) { setSelectedSlotId(''); return; } if (!selectedSlots.some((slot) => slot.id === selectedSlotId)) { setSelectedSlotId(selectedSlots[0].id); } }, [selectedSlotId, selectedSlots]); const cartItems = useMemo(() => { return Object.entries(cart) .map(([productId, quantity]) => { const product = products.find((item) => item.id === productId); if (!product || quantity <= 0) { return null; } return { ...product, quantity, }; }) .filter(Boolean) as Array; }, [cart, products]); const pricing = useMemo(() => { const subtotal = cartItems.reduce((sum, item) => sum + Number(item.price || 0) * item.quantity, 0); const tax = cartItems.reduce((sum, item) => { if (!item.is_taxable) { return sum; } return sum + Number(item.price || 0) * item.quantity * Number(item.tax_rate || 0); }, 0); const deliveryFee = fulfillmentMethod === 'delivery' && cartItems.length ? 4.99 : 0; const total = subtotal + tax + deliveryFee; return { subtotal, tax, deliveryFee, total, }; }, [cartItems, fulfillmentMethod]); const addToCart = (productId: string, amount = 1) => { setCart((previous) => { const currentQuantity = previous[productId] || 0; const product = products.find((item) => item.id === productId); const maxQuantity = Number(product?.stock_quantity || 0); const nextQuantity = Math.min(Math.max(currentQuantity + amount, 0), maxQuantity); if (!nextQuantity) { const nextCart = { ...previous }; delete nextCart[productId]; return nextCart; } return { ...previous, [productId]: nextQuantity, }; }); }; const handleCheckout = async () => { setSubmitError(''); setSuccessOrder(null); if (!cartItems.length) { setSubmitError('Add vegetables to the basket before placing your order.'); return; } if (!selectedSlotId) { setSubmitError(`Choose a ${fulfillmentMethod} slot to continue.`); return; } if (fulfillmentMethod === 'delivery' && selectedAddressId === 'new') { const requiredFields: Array = ['recipient_name', 'phone', 'line1', 'city', 'state', 'postal_code', 'country']; const missingField = requiredFields.find((field) => !newAddress[field]?.trim()); if (missingField) { setSubmitError('Complete the delivery address before placing your order.'); return; } } setIsSubmitting(true); try { const payload = { fulfillment_method: fulfillmentMethod, delivery_slotId: selectedSlotId, payment_provider: fulfillmentMethod === 'delivery' ? 'cash_on_delivery' : 'cash_on_pickup', customer_note: customerNote, items: cartItems.map((item) => ({ productId: item.id, quantity: item.quantity, })), ...(fulfillmentMethod === 'delivery' ? selectedAddressId !== 'new' ? { delivery_addressId: selectedAddressId } : { delivery_address: newAddress } : {}), }; const response = await axios.post('/orders/checkout', { data: payload }); const order = response.data as CheckoutOrder; setSuccessOrder(order); setCart({}); setCustomerNote(''); setSelectedAddressId(order.delivery_address?.id || (addresses[0]?.id ? addresses[0].id : 'new')); await loadStorefront(); } catch (error) { if (axios.isAxiosError(error)) { setSubmitError(error.response?.data || error.message || 'Your order could not be placed.'); } else { setSubmitError('Your order could not be placed.'); } } finally { setIsSubmitting(false); } }; const inputClassName = `w-full border border-gray-200 bg-white/80 px-3 py-2 text-sm text-gray-900 shadow-sm ${corners} ${focusRingColor}`; if (!hasReadProducts) { return ( <> {getPageTitle('Storefront')}

Storefront access is not enabled for this role

Ask an administrator to grant product and order permissions so you can browse vegetables and place orders.

); } return ( <> {getPageTitle('Storefront')}
Fresh, local, and easy to order

Build a beautiful first-order flow for your vegetable shop.

Browse your active products, curate a basket, choose delivery or pickup, and place an order in one polished flow.

{products.filter((product) => product.is_active !== false).length} active vegetables
{fulfillmentMethod === 'delivery' ? 'Delivery ready' : 'Pickup ready'}
Pay on {fulfillmentMethod}

How the MVP works

Catalog → basket → checkout → order detail

Add produce to a live basket with instant totals.
Reserve a delivery or pickup slot right in checkout.
Generate a real order, payment stub, and stock adjustment.
{loading ? ( ) : (
setSearch(event.target.value)} placeholder="Search carrots, basil, spinach…" />
{categories.map((category) => ( ))}
{loadError ? (

Storefront unavailable

{loadError}

) : null}
{filteredProducts.map((product) => { const inCart = cart[product.id] || 0; const stock = Number(product.stock_quantity || 0); const isSoldOut = stock <= 0; return (
{product.category?.name || 'Seasonal pick'}

{product.name}

{product.short_description || product.description || 'Fresh produce, ready to order.'}

{product.is_featured ? (
Featured
) : null}

Price

{formatCurrency(product.price)}

Unit

{product.unit_size ? `${product.unit_size} ` : ''} {product.unit || 'each'}

Stock

{isSoldOut ? 'Sold out' : `${stock} available`}

{inCart ? (
{inCart}
) : null} addToCart(product.id, 1)} />
); })}
{!filteredProducts.length ? (

No vegetables match this view yet

Try a different search, or add fresh products in the admin catalog.

) : null}

Recent orders

Track the latest checkout outcomes

{recentOrders.map((order) => (

{order.order_number || 'Draft order'}

{formatCurrency(order.total_amount)}

{String(order.status || 'processing').replace(/_/g, ' ')}
{order.fulfillment_method || 'pickup'}
{order.delivery_slot ? (
{formatSlotWindow(order.delivery_slot)}
) : null}
View order details
))}
{!recentOrders.length ? (
No orders yet. Place the first test order from the basket and it will appear here instantly.
) : null}

Basket & checkout

Finish the first order journey

{cartItems.reduce((sum, item) => sum + item.quantity, 0)} items
{(['delivery', 'pickup'] as const).map((option) => ( ))}
{!selectedSlots.length ? (

Add a {fulfillmentMethod} slot in admin before accepting this order type.

) : null}
{fulfillmentMethod === 'delivery' ? (
Delivery address
{addresses.map((address) => ( ))} {selectedAddressId === 'new' ? (
setNewAddress((previous) => ({ ...previous, label: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, recipient_name: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, phone: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, line1: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, line2: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, city: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, state: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, postal_code: event.target.value }))} /> setNewAddress((previous) => ({ ...previous, country: event.target.value }))} />
) : null}
) : (
Pickup orders skip the address step and create a cash-on-pickup payment stub automatically.
)}