diff --git a/backend/src/routes/orders.js b/backend/src/routes/orders.js
index c49a37c..3461b2f 100644
--- a/backend/src/routes/orders.js
+++ b/backend/src/routes/orders.js
@@ -103,6 +103,11 @@ router.post('/', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
+router.post('/checkout', wrapAsync(async (req, res) => {
+ const payload = await OrdersService.checkout(req.body.data, req.currentUser);
+ res.status(200).send(payload);
+}));
+
/**
* @swagger
* /api/budgets/bulk-import:
diff --git a/backend/src/services/orders.js b/backend/src/services/orders.js
index 2cac58d..9071392 100644
--- a/backend/src/services/orders.js
+++ b/backend/src/services/orders.js
@@ -1,15 +1,23 @@
const db = require('../db/models');
const OrdersDBApi = require('../db/api/orders');
-const processFile = require("../middlewares/upload");
+const Order_itemsDBApi = require('../db/api/order_items');
+const AddressesDBApi = require('../db/api/addresses');
+const PaymentsDBApi = require('../db/api/payments');
+const Inventory_adjustmentsDBApi = require('../db/api/inventory_adjustments');
+const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
-const axios = require('axios');
-const config = require('../config');
const stream = require('stream');
+const { Op } = db.Sequelize;
+const roundCurrency = (value) => Number(Number(value || 0).toFixed(2));
-
+const badRequest = (message) => {
+ const error = new Error(message);
+ error.code = 400;
+ return error;
+};
module.exports = class OrdersService {
static async create(data, currentUser) {
@@ -28,9 +36,285 @@ module.exports = class OrdersService {
await transaction.rollback();
throw error;
}
- };
+ }
- static async bulkImport(req, res, sendInvitationEmails = true, host) {
+ static async checkout(data, currentUser) {
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ if (!currentUser?.id) {
+ throw badRequest('You must be signed in to place an order.');
+ }
+
+ const fulfillmentMethod = data?.fulfillment_method;
+ if (!['delivery', 'pickup'].includes(fulfillmentMethod)) {
+ throw badRequest('Choose delivery or pickup before placing your order.');
+ }
+
+ const rawItems = Array.isArray(data?.items) ? data.items : [];
+ const aggregatedItems = rawItems.reduce((accumulator, item) => {
+ const productId = item?.productId;
+ const quantity = Number(item?.quantity || 0);
+
+ if (!productId || quantity <= 0) {
+ return accumulator;
+ }
+
+ accumulator[productId] = (accumulator[productId] || 0) + quantity;
+ return accumulator;
+ }, {});
+
+ const items = Object.entries(aggregatedItems).map(([productId, quantity]) => ({
+ productId,
+ quantity,
+ }));
+
+ if (!items.length) {
+ throw badRequest('Add at least one vegetable to your cart before checkout.');
+ }
+
+ const productIds = items.map((item) => item.productId);
+ const products = await db.products.findAll({
+ where: {
+ id: {
+ [Op.in]: productIds,
+ },
+ is_active: true,
+ },
+ transaction,
+ });
+
+ if (products.length !== productIds.length) {
+ throw badRequest('Some vegetables are no longer available. Refresh the catalog and try again.');
+ }
+
+ const productMap = new Map(products.map((product) => [product.id, product]));
+
+ let deliverySlot = null;
+ if (!data?.delivery_slotId) {
+ throw badRequest('Choose a delivery or pickup slot before placing your order.');
+ }
+
+ deliverySlot = await db.delivery_slots.findOne({
+ where: {
+ id: data.delivery_slotId,
+ is_active: true,
+ },
+ transaction,
+ });
+
+ if (!deliverySlot) {
+ throw badRequest('The selected slot is unavailable. Please choose another slot.');
+ }
+
+ if (deliverySlot.slot_type !== fulfillmentMethod) {
+ throw badRequest('The selected slot does not match your fulfillment method.');
+ }
+
+ if (
+ deliverySlot.capacity !== null
+ && deliverySlot.capacity !== undefined
+ && Number(deliverySlot.reserved_count || 0) >= Number(deliverySlot.capacity)
+ ) {
+ throw badRequest('That slot is full. Please choose a different time.');
+ }
+
+ let deliveryAddress = null;
+
+ if (fulfillmentMethod === 'delivery') {
+ if (data?.delivery_addressId) {
+ deliveryAddress = await db.addresses.findOne({
+ where: {
+ id: data.delivery_addressId,
+ userId: currentUser.id,
+ },
+ transaction,
+ });
+
+ if (!deliveryAddress) {
+ throw badRequest('We could not find the selected delivery address.');
+ }
+ } else {
+ const addressInput = data?.delivery_address || {};
+ const requiredFields = ['recipient_name', 'phone', 'line1', 'city', 'state', 'postal_code', 'country'];
+ const hasMissingFields = requiredFields.some((field) => !addressInput[field]);
+
+ if (hasMissingFields) {
+ throw badRequest('Complete the delivery address before placing your order.');
+ }
+
+ deliveryAddress = await AddressesDBApi.create(
+ {
+ address_type: 'delivery',
+ label: addressInput.label || 'Fresh delivery',
+ recipient_name: addressInput.recipient_name,
+ phone: addressInput.phone,
+ line1: addressInput.line1,
+ line2: addressInput.line2 || null,
+ city: addressInput.city,
+ state: addressInput.state,
+ postal_code: addressInput.postal_code,
+ country: addressInput.country,
+ is_default: false,
+ user: currentUser.id,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+ }
+ }
+
+ const paymentProvider = data?.payment_provider
+ || (fulfillmentMethod === 'delivery' ? 'cash_on_delivery' : 'cash_on_pickup');
+
+ if (!['cash_on_delivery', 'cash_on_pickup'].includes(paymentProvider)) {
+ throw badRequest('This first checkout supports pay on delivery or pay on pickup only.');
+ }
+
+ const lineItems = items.map((item) => {
+ const product = productMap.get(item.productId);
+ const quantity = Number(item.quantity);
+ const stockQuantity = Number(product.stock_quantity || 0);
+
+ if (stockQuantity < quantity) {
+ throw badRequest(`${product.name} only has ${stockQuantity} left in stock.`);
+ }
+
+ const unitPrice = roundCurrency(product.price);
+ const lineSubtotal = roundCurrency(unitPrice * quantity);
+ const lineTax = product.is_taxable
+ ? roundCurrency(lineSubtotal * Number(product.tax_rate || 0))
+ : 0;
+ const lineTotal = roundCurrency(lineSubtotal + lineTax);
+
+ return {
+ product,
+ quantity,
+ unitPrice,
+ lineSubtotal,
+ lineTax,
+ lineTotal,
+ };
+ });
+
+ const subtotalAmount = roundCurrency(
+ lineItems.reduce((sum, item) => sum + item.lineSubtotal, 0),
+ );
+ const taxAmount = roundCurrency(
+ lineItems.reduce((sum, item) => sum + item.lineTax, 0),
+ );
+ const deliveryFee = fulfillmentMethod === 'delivery' ? 4.99 : 0;
+ const totalAmount = roundCurrency(subtotalAmount + taxAmount + deliveryFee);
+ const orderNumber = `VEG-${new Date().toISOString().replace(/\D/g, '').slice(0, 12)}-${Math.floor(100 + Math.random() * 900)}`;
+
+ const order = await OrdersDBApi.create(
+ {
+ order_number: orderNumber,
+ status: 'processing',
+ fulfillment_method: fulfillmentMethod,
+ subtotal_amount: subtotalAmount,
+ discount_amount: 0,
+ tax_amount: taxAmount,
+ delivery_fee: deliveryFee,
+ total_amount: totalAmount,
+ payment_status: 'unpaid',
+ customer_note: data?.customer_note || null,
+ placed_at: new Date(),
+ user: currentUser.id,
+ delivery_slot: deliverySlot.id,
+ delivery_address: deliveryAddress?.id || null,
+ billing_address: deliveryAddress?.id || null,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+
+ for (const item of lineItems) {
+ await Order_itemsDBApi.create(
+ {
+ order: order.id,
+ product: item.product.id,
+ product_name: item.product.name,
+ product_sku: item.product.sku,
+ unit: item.product.unit,
+ unit_size: item.product.unit_size,
+ quantity: item.quantity,
+ unit_price: item.unitPrice,
+ line_subtotal: item.lineSubtotal,
+ line_tax: item.lineTax,
+ line_total: item.lineTotal,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+
+ const nextStockQuantity = Number(item.product.stock_quantity || 0) - item.quantity;
+ await item.product.update(
+ {
+ stock_quantity: nextStockQuantity,
+ updatedById: currentUser.id,
+ },
+ {
+ transaction,
+ },
+ );
+
+ await Inventory_adjustmentsDBApi.create(
+ {
+ product: item.product.id,
+ reason: 'sale',
+ quantity_change: -item.quantity,
+ note: `Order ${orderNumber}`,
+ effective_at: new Date(),
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+ }
+
+ await deliverySlot.update(
+ {
+ reserved_count: Number(deliverySlot.reserved_count || 0) + 1,
+ updatedById: currentUser.id,
+ },
+ {
+ transaction,
+ },
+ );
+
+ await PaymentsDBApi.create(
+ {
+ order: order.id,
+ provider: paymentProvider,
+ status: 'initiated',
+ amount: totalAmount,
+ currency: 'USD',
+ provider_reference: orderNumber,
+ },
+ {
+ currentUser,
+ transaction,
+ },
+ );
+
+ await transaction.commit();
+
+ return OrdersDBApi.findBy({ id: order.id });
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@@ -38,7 +322,7 @@ module.exports = class OrdersService {
const bufferStream = new stream.PassThrough();
const results = [];
- await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
+ await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
@@ -49,13 +333,13 @@ module.exports = class OrdersService {
resolve();
})
.on('error', (error) => reject(error));
- })
+ });
await OrdersDBApi.bulkImport(results, {
- transaction,
- ignoreDuplicates: true,
- validate: true,
- currentUser: req.currentUser
+ transaction,
+ ignoreDuplicates: true,
+ validate: true,
+ currentUser: req.currentUser,
});
await transaction.commit();
@@ -68,9 +352,9 @@ module.exports = class OrdersService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
- let orders = await OrdersDBApi.findBy(
- {id},
- {transaction},
+ const orders = await OrdersDBApi.findBy(
+ { id },
+ { transaction },
);
if (!orders) {
@@ -90,12 +374,11 @@ module.exports = class OrdersService {
await transaction.commit();
return updatedOrders;
-
} catch (error) {
await transaction.rollback();
throw error;
}
- };
+ }
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@@ -131,8 +414,4 @@ module.exports = class OrdersService {
throw error;
}
}
-
-
};
-
-
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 22ee02c..f0a91fc 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -8,6 +8,14 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard',
},
+ {
+ href: '/shop',
+ label: 'Storefront',
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ icon: 'mdiStorefrontOutline' in icon ? icon['mdiStorefrontOutline' as keyof typeof icon] : icon.mdiCart ?? icon.mdiTable,
+ },
+
{
href: '/users/users-list',
label: 'Users',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 447d483..410bced 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,219 @@
-
-import React, { useEffect, useState } from 'react';
+import {
+ mdiArrowRight,
+ mdiBasketOutline,
+ mdiCarrot,
+ mdiCheckCircleOutline,
+ mdiClockOutline,
+ mdiLeaf,
+ mdiShieldCheckOutline,
+ mdiStorefrontOutline,
+ mdiTruckFastOutline,
+} from '@mdi/js';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
+import React from 'react';
import BaseButton from '../components/BaseButton';
+import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const featureCards = [
+ {
+ icon: mdiCarrot,
+ title: 'Vegetable catalog',
+ description: 'Highlight your freshest produce with prices, units, stock visibility, and featured seasonal picks.',
+ },
+ {
+ icon: mdiBasketOutline,
+ title: 'Quick basket flow',
+ description: 'Let customers build a basket, review totals instantly, and move into checkout without friction.',
+ },
+ {
+ icon: mdiClockOutline,
+ title: 'Delivery or pickup',
+ description: 'Offer scheduled delivery or pickup windows so shoppers can choose how they receive fresh produce.',
+ },
+];
-export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('image');
- const [contentPosition, setContentPosition] = useState('background');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'Veggie Shop'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+const workflowSteps = [
+ {
+ icon: mdiLeaf,
+ title: 'Browse the produce',
+ description: 'Search the catalog, scan categories, and discover featured vegetables with clear pricing.',
+ },
+ {
+ icon: mdiTruckFastOutline,
+ title: 'Choose fulfillment',
+ description: 'Select delivery or pickup, reserve a time slot, and add any order notes.',
+ },
+ {
+ icon: mdiCheckCircleOutline,
+ title: 'Place and track',
+ description: 'Confirm the order, create the payment placeholder, and jump straight into order details.',
+ },
+];
+export default function HomePage() {
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Fresh vegetable storefront')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
+
+
+
+
+
+ Fresh, clean, and ready for local produce commerce
+
+
+
+ Sell vegetables online with a storefront that feels fresh from the first click.
+
+
+ This first MVP slice gives you a modern vegetable catalog, a basket-to-checkout journey, and a real admin-backed order flow with delivery or pickup scheduling.
+
+
+
+
+
+
+
+
+
+
Catalog
+
Seasonal produce cards with search, categories, and stock cues.
+
+
+
Checkout
+
Delivery or pickup slot selection with a fast, clear basket summary.
+
+
+
Operations
+
Real orders, payment placeholders, and inventory updates for admins.
+
+
-
-
-
-
-
-
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
+
+
+
+
+
+
+
Today's storefront
+
Simple, modern, and ready to iterate
+
+
MVP
+
+
+
+ Public landing page with direct access to login, admin, and storefront.
+
+
+ Authenticated storefront for browsing, basket building, and checkout.
+
+
+ Orders connect back to the admin interface you already have.
+
+
+
+
+
+
Brand feel
+
Fresh greens, soft neutrals, rounded cards, and airy spacing for a clean produce-first experience.
+
+
+
Admin ready
+
Use the generated CRUD screens to add products, prices, slots, and manage incoming orders.
+
+
+
+
+
+
-
+
+
+ {featureCards.map((feature) => (
+
+
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+
+
+ ))}
+
+
+
+
+
+
+
First customer journey
+
A thin slice that already feels like a real vegetable shop
+
+ Instead of shipping only a landing page, the first delivery connects browsing, checkout, confirmation, and order review into one usable loop.
+
+
+
+ {workflowSteps.map((step, index) => (
+
+
+ Step {index + 1}
+
+
{step.title}
+
{step.description}
+
+ ))}
+
+
+
+
+
+
+
+ >
);
}
-Starter.getLayout = function getLayout(page: ReactElement) {
+HomePage.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/shop.tsx b/frontend/src/pages/shop.tsx
new file mode 100644
index 0000000..bf7e29f
--- /dev/null
+++ b/frontend/src/pages/shop.tsx
@@ -0,0 +1,866 @@
+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 ? (
+
+
+
+ ) : (
+
+
+
+
+
+ Search vegetables
+ setSearch(event.target.value)}
+ placeholder="Search carrots, basil, spinach…"
+ />
+
+
+
Category
+
+ 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
+
+ {categories.map((category) => (
+ 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}
+
+ ))}
+
+
+
+
+
+ {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 ? (
+
+ addToCart(product.id, -1)} className="rounded-full p-1 text-emerald-700 transition hover:bg-emerald-50">
+
+
+ {inCart}
+ addToCart(product.id, 1)} className="rounded-full p-1 text-emerald-700 transition hover:bg-emerald-50" disabled={inCart >= stock}>
+
+
+
+ ) : 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) => (
+
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'}`}
+ >
+
+ {option}
+
+ {option === 'delivery' ? 'Drop-off with cash on delivery' : 'Scheduled farm pickup with cash on pickup'}
+
+ ))}
+
+
+
+
{fulfillmentMethod} slot
+
setSelectedSlotId(event.target.value)}>
+ Choose a slot
+ {selectedSlots.map((slot) => (
+ {slot.name} · {formatSlotWindow(slot)}
+ ))}
+
+ {!selectedSlots.length ? (
+
Add a {fulfillmentMethod} slot in admin before accepting this order type.
+ ) : null}
+
+
+ {fulfillmentMethod === 'delivery' ? (
+
+ ) : (
+
+ Pickup orders skip the address step and create a cash-on-pickup payment stub automatically.
+
+ )}
+
+
+ Order note
+
+
+
+ {cartItems.map((item) => (
+
+
+
{item.name}
+
{formatCurrency(item.price)} each
+
+
+
+ addToCart(item.id, -1)} className="rounded-full p-1 text-emerald-700 hover:bg-emerald-50">
+
+
+ {item.quantity}
+ addToCart(item.id, 1)} className="rounded-full p-1 text-emerald-700 hover:bg-emerald-50" disabled={item.quantity >= Number(item.stock_quantity || 0)}>
+
+
+
+
{formatCurrency(Number(item.price || 0) * item.quantity)}
+
+
+ ))}
+
+ {!cartItems.length ? (
+
+ Your basket is empty. Add a few vegetables from the catalog to unlock checkout.
+
+ ) : null}
+
+
+
+
+ Subtotal
+ {formatCurrency(pricing.subtotal)}
+
+
+ Tax
+ {formatCurrency(pricing.tax)}
+
+
+ {fulfillmentMethod === 'delivery' ? 'Delivery fee' : 'Pickup fee'}
+ {formatCurrency(pricing.deliveryFee)}
+
+
+ Total
+ {formatCurrency(pricing.total)}
+
+
+
+ {submitError ? (
+
+ {submitError}
+
+ ) : null}
+
+ {successOrder ? (
+
+
+
+
+
+
+
Order {successOrder.order_number} placed successfully.
+
Your basket has been converted into a real order with payment tracking and stock adjustments.
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+StorefrontPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default StorefrontPage;
diff --git a/frontend/src/pages/shop/orders/[orderId].tsx b/frontend/src/pages/shop/orders/[orderId].tsx
new file mode 100644
index 0000000..1792078
--- /dev/null
+++ b/frontend/src/pages/shop/orders/[orderId].tsx
@@ -0,0 +1,360 @@
+import {
+ mdiArrowLeft,
+ mdiCheckCircleOutline,
+ mdiClockOutline,
+ mdiCreditCardOutline,
+ mdiMapMarkerOutline,
+ mdiReceiptText,
+ mdiStorefrontOutline,
+ mdiTruckFastOutline,
+} from '@mdi/js';
+import Head from 'next/head';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+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';
+
+type DeliverySlot = {
+ name?: string | null;
+ starts_at?: string | null;
+ ends_at?: string | null;
+ slot_type?: 'delivery' | 'pickup' | null;
+};
+
+type Address = {
+ 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 OrderItem = {
+ id: string;
+ product_name?: string | null;
+ quantity?: number | null;
+ unit?: string | null;
+ unit_size?: number | string | null;
+ unit_price?: number | string | null;
+ line_total?: number | string | null;
+};
+
+type Payment = {
+ id: string;
+ provider?: string | null;
+ status?: string | null;
+ amount?: number | string | null;
+ currency?: string | null;
+};
+
+type OrderDetail = {
+ id: string;
+ order_number?: string | null;
+ status?: string | null;
+ fulfillment_method?: 'delivery' | 'pickup' | null;
+ subtotal_amount?: number | string | null;
+ tax_amount?: number | string | null;
+ delivery_fee?: number | string | null;
+ total_amount?: number | string | null;
+ payment_status?: string | null;
+ customer_note?: string | null;
+ placed_at?: string | null;
+ delivery_slot?: DeliverySlot | null;
+ delivery_address?: Address | null;
+ order_items_order?: OrderItem[];
+ payments_order?: Payment[];
+};
+
+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 formatDateTime = (value?: string | null) => {
+ if (!value) {
+ return 'Not scheduled yet';
+ }
+
+ return new Date(value).toLocaleString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+};
+
+const formatAddress = (address?: Address | null) => {
+ if (!address) {
+ return 'No address attached';
+ }
+
+ return [
+ address.label,
+ address.recipient_name,
+ address.phone,
+ address.line1,
+ address.line2,
+ [address.city, address.state].filter(Boolean).join(', '),
+ address.postal_code,
+ address.country,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+};
+
+const ShopOrderDetailPage = () => {
+ const router = useRouter();
+ const { orderId } = router.query;
+ const [order, setOrder] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!orderId || typeof orderId !== 'string') {
+ return;
+ }
+
+ const fetchOrder = async () => {
+ setLoading(true);
+ setError('');
+
+ try {
+ const response = await axios.get(`/orders/${orderId}`);
+ setOrder(response.data);
+ } catch (fetchError) {
+ if (axios.isAxiosError(fetchError)) {
+ setError(fetchError.response?.data || fetchError.message || 'Unable to load this order.');
+ } else {
+ setError('Unable to load this order.');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchOrder();
+ }, [orderId]);
+
+ const payment = useMemo(() => order?.payments_order?.[0] || null, [order]);
+
+ return (
+ <>
+
+ {getPageTitle(order?.order_number || 'Order detail')}
+
+
+
+
+
+ Open admin order list
+
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+
We couldn't load this order
+
{error}
+
+
+ ) : !order ? (
+
+
+
Order not found
+
Try returning to the storefront and placing a fresh order.
+
+
+ ) : (
+
+
+
+
+
+ Order detail
+
+
+
{order.order_number}
+
{formatCurrency(order.total_amount)}
+
+ This order was placed on {formatDateTime(order.placed_at)} and is currently {String(order.status || 'processing').replace(/_/g, ' ')}.
+
+
+
+
+
+
+
+
Fulfillment
+
{order.fulfillment_method || 'pickup'}
+
+
+
+
+
+
Slot
+
{order.delivery_slot?.name || 'Time pending'}
+
{formatDateTime(order.delivery_slot?.starts_at)}
+
+
+
+
+
+
Payment status
+
{String(order.payment_status || 'unpaid').replace(/_/g, ' ')}
+
+
+
+
+
+
+
+
+
+
+
Line items
+
What's in the order
+
+
+ {order.order_items_order?.map((item) => (
+
+
+
{item.product_name}
+
+ {item.quantity} × {formatCurrency(item.unit_price)}
+ {item.unit ? ` · ${item.unit_size || ''} ${item.unit}` : ''}
+
+
+
{formatCurrency(item.line_total)}
+
+ ))}
+
+ {!order.order_items_order?.length ? (
+
+ This order does not have any line items yet.
+
+ ) : null}
+
+
+
+
+
+
+
+
Summary
+
Charges & notes
+
+
+
+ Subtotal
+ {formatCurrency(order.subtotal_amount)}
+
+
+ Tax
+ {formatCurrency(order.tax_amount)}
+
+
+ Delivery fee
+ {formatCurrency(order.delivery_fee)}
+
+
+ Total
+ {formatCurrency(order.total_amount)}
+
+
+ {order.customer_note ? (
+
+
Customer note
+
{order.customer_note}
+
+ ) : null}
+
+
+
+
+
+
+
Fulfillment
+
Delivery or pickup details
+
+
+
+
+
+
{order.delivery_slot?.name || 'Slot pending'}
+
{formatDateTime(order.delivery_slot?.starts_at)}
+
+
+ {order.fulfillment_method === 'delivery' ? (
+
+
+
+
Delivery address
+
{formatAddress(order.delivery_address)}
+
+
+ ) : (
+
+
+
+
Pickup order
+
Collect this order during the reserved pickup window and mark payment on arrival.
+
+
+ )}
+
+
+
+
+
+
+
+
Payment
+
Recorded checkout payment
+
+
+
+
+
+
{payment?.provider ? String(payment.provider).replace(/_/g, ' ') : 'Payment placeholder'}
+
Status: {payment?.status ? String(payment.status).replace(/_/g, ' ') : 'initiated'}
+
Amount: {formatCurrency(payment?.amount || order.total_amount)} {payment?.currency || 'USD'}
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+ShopOrderDetailPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default ShopOrderDetailPage;