From 6665c5048a39f40656db8ae906975a448c45cc7c Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 26 Mar 2026 17:06:10 +0000 Subject: [PATCH] veg --- backend/src/routes/orders.js | 5 + backend/src/services/orders.js | 321 ++++++- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/index.tsx | 341 +++++--- frontend/src/pages/shop.tsx | 866 +++++++++++++++++++ frontend/src/pages/shop/orders/[orderId].tsx | 360 ++++++++ 8 files changed, 1738 insertions(+), 169 deletions(-) create mode 100644 frontend/src/pages/shop.tsx create mode 100644 frontend/src/pages/shop/orders/[orderId].tsx 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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +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

+
+
+
+ + + + + + Veggie Shop + Fresh produce, modern ordering + + + +
+
+ +
+
+
+
+ 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}

+
+ ))} +
+
+
+ + +
+
+

© 2026 Veggie Shop. Fresh vegetables with a clean modern buying flow.

+
+ Privacy Policy + Terms of Use + Login +
+
+
+
+ ); } -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 ? ( + + + + ) : ( +
+
+ +
+
+ + 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. +
+ )} + +
+ +