Flatlogic Bot 6665c5048a veg
2026-03-26 17:06:10 +00:00

867 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Product[]>([]);
const [deliverySlots, setDeliverySlots] = useState<DeliverySlot[]>([]);
const [addresses, setAddresses] = useState<Address[]>([]);
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
const [cart, setCart] = useState<Record<string, number>>({});
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<NewAddressForm>(initialAddressForm);
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [loadError, setLoadError] = useState('');
const [submitError, setSubmitError] = useState('');
const [successOrder, setSuccessOrder] = useState<CheckoutOrder | null>(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<string, Category>();
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<Product & { quantity: number }>;
}, [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<keyof NewAddressForm> = ['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 (
<>
<Head>
<title>{getPageTitle('Storefront')}</title>
</Head>
<SectionMain>
<CardBox className="border-emerald-100 bg-white/90">
<div className="space-y-3">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700">
<BaseIcon path={mdiStorefrontOutline} size={26} />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Storefront access is not enabled for this role</h1>
<p className="text-sm text-gray-600">
Ask an administrator to grant product and order permissions so you can browse vegetables and place orders.
</p>
<BaseButton href="/dashboard" color="info" label="Back to dashboard" />
</div>
</CardBox>
</SectionMain>
</>
);
}
return (
<>
<Head>
<title>{getPageTitle('Storefront')}</title>
</Head>
<SectionMain>
<section className="mb-8 overflow-hidden rounded-[2rem] bg-gradient-to-br from-emerald-600 via-green-600 to-lime-500 text-white shadow-xl shadow-emerald-200/70">
<div className="grid gap-6 px-6 py-8 md:px-8 lg:grid-cols-[1.4fr_0.9fr] lg:items-center lg:px-10 lg:py-10">
<div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-50">
<BaseIcon path={mdiLeaf} size={16} /> Fresh, local, and easy to order
</div>
<div className="space-y-3">
<h1 className="max-w-2xl text-4xl font-bold tracking-tight md:text-5xl">Build a beautiful first-order flow for your vegetable shop.</h1>
<p className="max-w-2xl text-sm leading-6 text-emerald-50/90 md:text-base">
Browse your active products, curate a basket, choose delivery or pickup, and place an order in one polished flow.
</p>
</div>
<div className="flex flex-wrap gap-3 text-sm">
<div className="inline-flex items-center gap-2 rounded-2xl bg-white/10 px-4 py-2 backdrop-blur">
<BaseIcon path={mdiCarrot} size={18} /> {products.filter((product) => product.is_active !== false).length} active vegetables
</div>
<div className="inline-flex items-center gap-2 rounded-2xl bg-white/10 px-4 py-2 backdrop-blur">
<BaseIcon path={fulfillmentMethod === 'delivery' ? mdiTruckFastOutline : mdiStorefrontOutline} size={18} /> {fulfillmentMethod === 'delivery' ? 'Delivery ready' : 'Pickup ready'}
</div>
<div className="inline-flex items-center gap-2 rounded-2xl bg-white/10 px-4 py-2 backdrop-blur">
<BaseIcon path={mdiCashFast} size={18} /> Pay on {fulfillmentMethod}
</div>
</div>
</div>
<div className="grid gap-4 rounded-[1.75rem] border border-white/15 bg-white/10 p-5 backdrop-blur">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-emerald-100">How the MVP works</p>
<h2 className="mt-2 text-2xl font-semibold">Catalog basket checkout order detail</h2>
</div>
<div className="grid gap-3 text-sm text-emerald-50/90">
<div className="flex items-start gap-3 rounded-2xl bg-black/10 px-4 py-3">
<BaseIcon path={mdiBasketOutline} size={18} className="mt-0.5" /> Add produce to a live basket with instant totals.
</div>
<div className="flex items-start gap-3 rounded-2xl bg-black/10 px-4 py-3">
<BaseIcon path={mdiClockOutline} size={18} className="mt-0.5" /> Reserve a delivery or pickup slot right in checkout.
</div>
<div className="flex items-start gap-3 rounded-2xl bg-black/10 px-4 py-3">
<BaseIcon path={mdiCheckCircleOutline} size={18} className="mt-0.5" /> Generate a real order, payment stub, and stock adjustment.
</div>
</div>
</div>
</div>
</section>
{loading ? (
<CardBox className="min-h-[20rem] justify-center">
<LoadingSpinner />
</CardBox>
) : (
<div className="grid gap-6 xl:grid-cols-[1.4fr_0.95fr]">
<div className="space-y-6">
<CardBox className="border-emerald-100 bg-white/90">
<div className="grid gap-4 lg:grid-cols-[1.3fr_1fr]">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Search vegetables</label>
<input
className={inputClassName}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search carrots, basil, spinach…"
/>
</div>
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Category</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setSelectedCategory('all')}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${selectedCategory === 'all' ? 'bg-emerald-600 text-white shadow-lg shadow-emerald-100' : 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'}`}
>
All produce
</button>
{categories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => setSelectedCategory(category.id)}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${selectedCategory === category.id ? 'bg-emerald-600 text-white shadow-lg shadow-emerald-100' : 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'}`}
>
{category.name}
</button>
))}
</div>
</div>
</div>
</CardBox>
{loadError ? (
<CardBox className="border border-rose-200 bg-rose-50">
<div className="space-y-2">
<h2 className="text-lg font-semibold text-rose-800">Storefront unavailable</h2>
<p className="text-sm text-rose-700">{loadError}</p>
</div>
</CardBox>
) : null}
<div className="grid gap-4 md:grid-cols-2">
{filteredProducts.map((product) => {
const inCart = cart[product.id] || 0;
const stock = Number(product.stock_quantity || 0);
const isSoldOut = stock <= 0;
return (
<CardBox key={product.id} className="border border-emerald-100 bg-white/90 shadow-sm shadow-emerald-100/60">
<div className="flex h-full flex-col gap-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
<BaseIcon path={mdiSprout} size={14} />
{product.category?.name || 'Seasonal pick'}
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">{product.name}</h2>
<p className={`mt-1 text-sm ${textSecondary || 'text-gray-500'}`}>
{product.short_description || product.description || 'Fresh produce, ready to order.'}
</p>
</div>
</div>
{product.is_featured ? (
<div className="rounded-full bg-lime-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-lime-700">Featured</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-500">Price</p>
<p className="mt-1 text-lg font-semibold text-gray-900">{formatCurrency(product.price)}</p>
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-500">Unit</p>
<p className="mt-1 text-lg font-semibold text-gray-900">
{product.unit_size ? `${product.unit_size} ` : ''}
{product.unit || 'each'}
</p>
</div>
</div>
<div className="mt-auto flex items-center justify-between gap-4 rounded-2xl border border-emerald-100 bg-emerald-50/70 px-4 py-3">
<div>
<p className="text-xs uppercase tracking-wide text-emerald-700">Stock</p>
<p className="font-semibold text-emerald-900">{isSoldOut ? 'Sold out' : `${stock} available`}</p>
</div>
<div className="flex items-center gap-2">
{inCart ? (
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white px-2 py-1 shadow-sm">
<button type="button" aria-label={`Decrease ${product.name}`} onClick={() => addToCart(product.id, -1)} className="rounded-full p-1 text-emerald-700 transition hover:bg-emerald-50">
<BaseIcon path={mdiMinus} size={14} />
</button>
<span className="min-w-6 text-center text-sm font-semibold text-emerald-900">{inCart}</span>
<button type="button" aria-label={`Increase ${product.name}`} onClick={() => addToCart(product.id, 1)} className="rounded-full p-1 text-emerald-700 transition hover:bg-emerald-50" disabled={inCart >= stock}>
<BaseIcon path={mdiPlus} size={14} />
</button>
</div>
) : null}
<BaseButton
color="success"
label={isSoldOut ? 'Sold out' : inCart ? 'Add more' : 'Add to basket'}
icon={mdiBasketOutline}
disabled={isSoldOut}
onClick={() => addToCart(product.id, 1)}
/>
</div>
</div>
</div>
</CardBox>
);
})}
</div>
{!filteredProducts.length ? (
<CardBox className="border-dashed border-emerald-200 bg-white/80">
<div className="space-y-2 py-6 text-center">
<h2 className="text-lg font-semibold text-gray-900">No vegetables match this view yet</h2>
<p className="text-sm text-gray-500">Try a different search, or add fresh products in the admin catalog.</p>
<div className="flex justify-center">
<BaseButton href="/products/products-new" color="info" label="Add a product" />
</div>
</div>
</CardBox>
) : null}
<CardBox className="border-emerald-100 bg-white/90">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Recent orders</p>
<h2 className="mt-1 text-2xl font-semibold text-gray-900">Track the latest checkout outcomes</h2>
</div>
<BaseButton href="/orders/orders-list" color="info" label="Admin orders" icon={mdiArrowRight} />
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{recentOrders.map((order) => (
<div key={order.id} className="rounded-[1.5rem] border border-gray-100 bg-gray-50 p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">{order.order_number || 'Draft order'}</p>
<h3 className="mt-1 text-lg font-semibold text-gray-900">{formatCurrency(order.total_amount)}</h3>
</div>
<div className="rounded-full bg-white px-3 py-1 text-xs font-semibold capitalize text-gray-700 shadow-sm">
{String(order.status || 'processing').replace(/_/g, ' ')}
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<BaseIcon path={order.fulfillment_method === 'delivery' ? mdiTruckFastOutline : mdiStorefrontOutline} size={16} className="text-emerald-600" />
<span className="capitalize">{order.fulfillment_method || 'pickup'}</span>
</div>
{order.delivery_slot ? (
<div className="flex items-start gap-2">
<BaseIcon path={mdiClockOutline} size={16} className="mt-0.5 text-emerald-600" />
<span>{formatSlotWindow(order.delivery_slot)}</span>
</div>
) : null}
</div>
<div className="mt-4">
<Link href={`/shop/orders/${order.id}`} className="inline-flex items-center gap-2 text-sm font-semibold text-emerald-700 hover:text-emerald-800">
View order details <BaseIcon path={mdiArrowRight} size={16} />
</Link>
</div>
</div>
))}
</div>
{!recentOrders.length ? (
<div className="mt-5 rounded-[1.5rem] border border-dashed border-emerald-200 bg-emerald-50/50 px-5 py-6 text-sm text-emerald-800">
No orders yet. Place the first test order from the basket and it will appear here instantly.
</div>
) : null}
</CardBox>
</div>
<div className="space-y-6">
<CardBox className="sticky top-20 border-emerald-100 bg-white/95 shadow-lg shadow-emerald-100/60">
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Basket & checkout</p>
<h2 className="mt-1 text-2xl font-semibold text-gray-900">Finish the first order journey</h2>
</div>
<div className="rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-800">
{cartItems.reduce((sum, item) => sum + item.quantity, 0)} items
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{(['delivery', 'pickup'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setFulfillmentMethod(option)}
className={`rounded-[1.35rem] border px-4 py-3 text-left transition ${fulfillmentMethod === option ? 'border-emerald-500 bg-emerald-50 text-emerald-900 shadow-md shadow-emerald-100' : 'border-gray-200 bg-white text-gray-700 hover:border-emerald-200 hover:bg-emerald-50/50'}`}
>
<div className="flex items-center gap-2 text-sm font-semibold capitalize">
<BaseIcon path={option === 'delivery' ? mdiTruckFastOutline : mdiStorefrontOutline} size={18} /> {option}
</div>
<p className="mt-2 text-xs text-gray-500">{option === 'delivery' ? 'Drop-off with cash on delivery' : 'Scheduled farm pickup with cash on pickup'}</p>
</button>
))}
</div>
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">{fulfillmentMethod} slot</label>
<select className={inputClassName} value={selectedSlotId} onChange={(event) => setSelectedSlotId(event.target.value)}>
<option value="">Choose a slot</option>
{selectedSlots.map((slot) => (
<option key={slot.id} value={slot.id}>{slot.name} · {formatSlotWindow(slot)}</option>
))}
</select>
{!selectedSlots.length ? (
<p className="mt-2 text-sm text-amber-700">Add a {fulfillmentMethod} slot in admin before accepting this order type.</p>
) : null}
</div>
{fulfillmentMethod === 'delivery' ? (
<div className="space-y-3 rounded-[1.5rem] border border-emerald-100 bg-emerald-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-900">
<BaseIcon path={mdiMapMarkerOutline} size={18} /> Delivery address
</div>
{addresses.map((address) => (
<label key={address.id} className={`flex cursor-pointer gap-3 rounded-2xl border px-4 py-3 text-sm transition ${selectedAddressId === address.id ? 'border-emerald-500 bg-white shadow-sm' : 'border-emerald-100 bg-white/70 hover:border-emerald-200'}`}>
<input type="radio" checked={selectedAddressId === address.id} onChange={() => setSelectedAddressId(address.id)} />
<div>
<p className="font-semibold text-gray-900">{address.label || address.recipient_name || 'Saved address'}</p>
<p className="mt-1 text-gray-600">{formatAddress(address)}</p>
</div>
</label>
))}
<label className={`flex cursor-pointer gap-3 rounded-2xl border px-4 py-3 text-sm transition ${selectedAddressId === 'new' ? 'border-emerald-500 bg-white shadow-sm' : 'border-emerald-100 bg-white/70 hover:border-emerald-200'}`}>
<input type="radio" checked={selectedAddressId === 'new'} onChange={() => setSelectedAddressId('new')} />
<div>
<p className="font-semibold text-gray-900">New address</p>
<p className="mt-1 text-gray-600">Create a fresh delivery destination while checking out.</p>
</div>
</label>
{selectedAddressId === 'new' ? (
<div className="grid gap-3 md:grid-cols-2">
<input className={inputClassName} placeholder="Label" value={newAddress.label} onChange={(event) => setNewAddress((previous) => ({ ...previous, label: event.target.value }))} />
<input className={inputClassName} placeholder="Recipient name" value={newAddress.recipient_name} onChange={(event) => setNewAddress((previous) => ({ ...previous, recipient_name: event.target.value }))} />
<input className={inputClassName} placeholder="Phone" value={newAddress.phone} onChange={(event) => setNewAddress((previous) => ({ ...previous, phone: event.target.value }))} />
<input className={inputClassName} placeholder="Line 1" value={newAddress.line1} onChange={(event) => setNewAddress((previous) => ({ ...previous, line1: event.target.value }))} />
<input className={inputClassName} placeholder="Line 2" value={newAddress.line2} onChange={(event) => setNewAddress((previous) => ({ ...previous, line2: event.target.value }))} />
<input className={inputClassName} placeholder="City" value={newAddress.city} onChange={(event) => setNewAddress((previous) => ({ ...previous, city: event.target.value }))} />
<input className={inputClassName} placeholder="State" value={newAddress.state} onChange={(event) => setNewAddress((previous) => ({ ...previous, state: event.target.value }))} />
<input className={inputClassName} placeholder="Postal code" value={newAddress.postal_code} onChange={(event) => setNewAddress((previous) => ({ ...previous, postal_code: event.target.value }))} />
<input className={`md:col-span-2 ${inputClassName}`} placeholder="Country" value={newAddress.country} onChange={(event) => setNewAddress((previous) => ({ ...previous, country: event.target.value }))} />
</div>
) : null}
</div>
) : (
<div className="rounded-[1.5rem] border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-900">
Pickup orders skip the address step and create a cash-on-pickup payment stub automatically.
</div>
)}
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Order note</label>
<textarea className={`${inputClassName} min-h-28`} value={customerNote} onChange={(event) => setCustomerNote(event.target.value)} placeholder="Gate code, ripeness preferences, or pickup instructions" />
</div>
<div className="space-y-3 rounded-[1.5rem] border border-gray-100 bg-gray-50 p-4">
{cartItems.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-2xl bg-white px-4 py-3 shadow-sm">
<div>
<p className="font-semibold text-gray-900">{item.name}</p>
<p className="text-xs text-gray-500">{formatCurrency(item.price)} each</p>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 px-2 py-1">
<button type="button" onClick={() => addToCart(item.id, -1)} className="rounded-full p-1 text-emerald-700 hover:bg-emerald-50">
<BaseIcon path={mdiMinus} size={14} />
</button>
<span className="min-w-6 text-center text-sm font-semibold text-emerald-900">{item.quantity}</span>
<button type="button" onClick={() => addToCart(item.id, 1)} className="rounded-full p-1 text-emerald-700 hover:bg-emerald-50" disabled={item.quantity >= Number(item.stock_quantity || 0)}>
<BaseIcon path={mdiPlus} size={14} />
</button>
</div>
<span className="min-w-20 text-right text-sm font-semibold text-gray-900">{formatCurrency(Number(item.price || 0) * item.quantity)}</span>
</div>
</div>
))}
{!cartItems.length ? (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white px-4 py-6 text-center text-sm text-gray-500">
Your basket is empty. Add a few vegetables from the catalog to unlock checkout.
</div>
) : null}
</div>
<div className="space-y-2 rounded-[1.5rem] bg-gray-950 px-5 py-4 text-white shadow-xl shadow-gray-200">
<div className="flex items-center justify-between text-sm text-white/80">
<span>Subtotal</span>
<span>{formatCurrency(pricing.subtotal)}</span>
</div>
<div className="flex items-center justify-between text-sm text-white/80">
<span>Tax</span>
<span>{formatCurrency(pricing.tax)}</span>
</div>
<div className="flex items-center justify-between text-sm text-white/80">
<span>{fulfillmentMethod === 'delivery' ? 'Delivery fee' : 'Pickup fee'}</span>
<span>{formatCurrency(pricing.deliveryFee)}</span>
</div>
<div className="mt-3 flex items-center justify-between border-t border-white/10 pt-3 text-lg font-semibold">
<span>Total</span>
<span>{formatCurrency(pricing.total)}</span>
</div>
</div>
{submitError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{submitError}
</div>
) : null}
{successOrder ? (
<div className="rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-900">
<div className="flex items-start gap-3">
<div className="rounded-full bg-white p-2 text-emerald-600 shadow-sm">
<BaseIcon path={mdiCheckCircleOutline} size={18} />
</div>
<div className="space-y-2">
<p className="font-semibold">Order {successOrder.order_number} placed successfully.</p>
<p>Your basket has been converted into a real order with payment tracking and stock adjustments.</p>
<div className="flex flex-wrap gap-3 pt-1">
<BaseButton href={`/shop/orders/${successOrder.id}`} color="success" label="View order detail" />
<BaseButton href="/orders/orders-list" color="whiteDark" outline label="Open admin orders" />
</div>
</div>
</div>
</div>
) : null}
<BaseButton
color="success"
label={isSubmitting ? 'Placing order…' : canCheckout ? 'Place order' : 'Checkout unavailable'}
icon={mdiCheckCircleOutline}
className="w-full justify-center py-3 text-base font-semibold"
disabled={isSubmitting || !canCheckout}
onClick={handleCheckout}
/>
</div>
</CardBox>
</div>
</div>
)}
</SectionMain>
</>
);
};
StorefrontPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default StorefrontPage;