diff --git a/backend/src/routes/orders.js b/backend/src/routes/orders.js index e51beb3..42283d4 100644 --- a/backend/src/routes/orders.js +++ b/backend/src/routes/orders.js @@ -102,9 +102,8 @@ router.use(checkCrudPermissions('orders')); router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - await OrdersService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); + const createdOrder = await OrdersService.create(req.body.data, req.currentUser, true, link.host); + res.status(200).send(createdOrder); })); /** diff --git a/backend/src/services/orders.js b/backend/src/services/orders.js index 2cac58d..de5b8b6 100644 --- a/backend/src/services/orders.js +++ b/backend/src/services/orders.js @@ -15,7 +15,7 @@ module.exports = class OrdersService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await OrdersDBApi.create( + const createdOrder = await OrdersDBApi.create( data, { currentUser, @@ -24,6 +24,7 @@ module.exports = class OrdersService { ); await transaction.commit(); + return createdOrder; } catch (error) { await transaction.rollback(); throw error; @@ -135,4 +136,3 @@ module.exports = class OrdersService { }; - diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 87bb53c..b2d82a6 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index f2f373a..a0f77e9 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -3,6 +3,11 @@ import Select, { components, SingleValueProps, OptionProps } from 'react-select' type LanguageOption = { label: string; value: string }; +type Props = { + value?: string; + onChange?: (value: string) => void; +}; + const LANGS: LanguageOption[] = [ { value: 'en', label: '🇬🇧 EN' }, { value: 'fr', label: '🇫🇷 FR' }, @@ -22,7 +27,7 @@ const SingleVal = (props: SingleValueProps) => ( ); -const LanguageSwitcher: React.FC = () => { +const LanguageSwitcher: React.FC = ({ value, onChange }) => { const [mounted, setMounted] = useState(false); const [selected, setSelected] = useState(LANGS[0]); @@ -30,9 +35,20 @@ const LanguageSwitcher: React.FC = () => { setMounted(true); }, []); + useEffect(() => { + if (!value) return; + const nextSelection = LANGS.find((option) => option.value === value); + if (nextSelection) { + setSelected(nextSelection); + } + }, [value]); + const handleChange = (opt: LanguageOption | null) => { if (!opt) return; setSelected(opt); + if (onChange) { + onChange(opt.value); + } }; if (!mounted) return null; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..995a452 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 024d310..af0f750 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -112,6 +112,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiReceiptText' in icon ? icon['mdiReceiptText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_ORDERS' }, + { + href: '/marketplace/quick-order', + label: 'Quick order', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiCart ?? icon.mdiTable, + permissions: 'READ_ORDERS' + }, { href: '/order_items/order_items-list', label: 'Order items', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 395ecc3..cf124af 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,340 @@ - -import React, { useEffect, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import BaseButton from '../components/BaseButton'; -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 CardBox from '../components/CardBox'; +import LayoutGuest from '../layouts/Guest'; +import LanguageSwitcher from '../components/LanguageSwitcher'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const copyByLanguage = { + en: { + title: 'Community Logistics Marketplace', + subtitle: + 'Connect neighborhood shops, grocery stores, and local couriers with customers who want fast, affordable delivery.', + ctaPrimary: 'Start ordering', + ctaSecondary: 'Admin console', + highlight: 'Create local jobs with walking, bike, and car deliveries.', + stats: [ + { label: 'Local delivery types', value: 'Walk • Bike • Car' }, + { label: 'Typical sales lift', value: '+35%' }, + { label: 'Platform commission', value: 'Small per-order fee' }, + ], + steps: [ + { + title: 'Shops list products', + description: + 'Retailers and wholesalers publish their catalog and delivery windows.', + }, + { + title: 'Customers place orders', + description: + 'Buyers choose a store, checkout quickly, and leave delivery notes.', + }, + { + title: 'Couriers deliver locally', + description: + 'Delivery partners accept jobs and complete them within their community.', + }, + ], + roles: [ + { + title: 'Stores & Wholesalers', + description: + 'Reach more customers, manage inventory, and grow local sales.', + }, + { + title: 'Delivery Partners', + description: + 'Earn income with flexible walking, biking, or driving shifts.', + }, + { + title: 'Customers', + description: + 'Get essentials quickly from trusted nearby businesses.', + }, + ], + ctaTitle: 'Launch your community delivery network today.', + ctaNote: + 'Use the admin console to onboard stores, manage couriers, and monitor orders.', + }, + fr: { + title: 'Marché logistique communautaire', + subtitle: + 'Reliez les commerces locaux, épiceries et livreurs de proximité aux clients qui veulent une livraison rapide.', + ctaPrimary: 'Commander maintenant', + ctaSecondary: 'Console admin', + highlight: 'Créez des emplois locaux avec livraison à pied, vélo ou voiture.', + stats: [ + { label: 'Types de livraison', value: 'À pied • Vélo • Voiture' }, + { label: 'Hausse des ventes', value: '+35%' }, + { label: 'Commission plateforme', value: 'Petite commission par commande' }, + ], + steps: [ + { + title: 'Les magasins publient', + description: + 'Les commerçants mettent en ligne leurs produits et créneaux.', + }, + { + title: 'Les clients commandent', + description: + 'Les clients choisissent un magasin et ajoutent des notes de livraison.', + }, + { + title: 'Les livreurs livrent', + description: + 'Les partenaires acceptent les courses et livrent localement.', + }, + ], + roles: [ + { + title: 'Commerces & Grossistes', + description: 'Touchez plus de clients et augmentez vos ventes locales.', + }, + { + title: 'Livreurs', + description: 'Gagnez un revenu flexible à pied, à vélo ou en voiture.', + }, + { + title: 'Clients', + description: 'Recevez rapidement les essentiels près de chez vous.', + }, + ], + ctaTitle: 'Lancez votre réseau de livraison locale dès maintenant.', + ctaNote: + 'Utilisez la console admin pour gérer magasins, livreurs et commandes.', + }, + es: { + title: 'Mercado logístico comunitario', + subtitle: + 'Conecta tiendas locales, supermercados y repartidores con clientes que quieren entregas rápidas.', + ctaPrimary: 'Empezar a pedir', + ctaSecondary: 'Consola admin', + highlight: 'Crea empleos locales con entregas a pie, bici o auto.', + stats: [ + { label: 'Tipos de entrega', value: 'A pie • Bici • Auto' }, + { label: 'Aumento de ventas', value: '+35%' }, + { label: 'Comisión plataforma', value: 'Pequeña comisión por pedido' }, + ], + steps: [ + { + title: 'Tiendas publican productos', + description: + 'Los comercios cargan su catálogo y horarios de entrega.', + }, + { + title: 'Clientes hacen pedidos', + description: + 'Los clientes eligen tienda, pagan rápido y dejan notas.', + }, + { + title: 'Repartidores entregan', + description: + 'Los socios aceptan trabajos y entregan en su comunidad.', + }, + ], + roles: [ + { + title: 'Tiendas y Mayoristas', + description: + 'Llega a más clientes y aumenta las ventas locales.', + }, + { + title: 'Repartidores', + description: 'Gana ingresos con turnos flexibles.', + }, + { + title: 'Clientes', + description: + 'Recibe productos esenciales de negocios cercanos.', + }, + ], + ctaTitle: 'Lanza tu red de entregas comunitarias hoy.', + ctaNote: + 'Gestiona tiendas, repartidores y pedidos desde la consola.', + }, + de: { + title: 'Community-Logistik-Marktplatz', + subtitle: + 'Verbinde lokale Geschäfte, Lebensmittelmärkte und Kuriere mit Kund:innen für schnelle Lieferungen.', + ctaPrimary: 'Jetzt bestellen', + ctaSecondary: 'Admin-Konsole', + highlight: + 'Schaffe lokale Jobs mit Lieferungen zu Fuß, per Rad oder Auto.', + stats: [ + { label: 'Lieferarten', value: 'Zu Fuß • Rad • Auto' }, + { label: 'Umsatzsteigerung', value: '+35%' }, + { label: 'Plattformgebühr', value: 'Kleine Gebühr pro Auftrag' }, + ], + steps: [ + { + title: 'Shops listen Produkte', + description: + 'Händler veröffentlichen ihren Katalog und Lieferfenster.', + }, + { + title: 'Kunden bestellen', + description: + 'Kund:innen wählen einen Laden, zahlen schnell und hinterlassen Hinweise.', + }, + { + title: 'Kuriere liefern lokal', + description: + 'Lieferpartner nehmen Aufträge an und liefern in der Community.', + }, + ], + roles: [ + { + title: 'Geschäfte & Großhändler', + description: + 'Erreiche mehr Kund:innen und steigere lokale Umsätze.', + }, + { + title: 'Lieferpartner', + description: + 'Verdiene flexibel mit Lauf-, Rad- oder Autofahrten.', + }, + { + title: 'Kund:innen', + description: + 'Erhalte schnell Essentials von lokalen Betrieben.', + }, + ], + ctaTitle: 'Starte dein lokales Liefernetzwerk jetzt.', + ctaNote: + 'Nutze die Admin-Konsole, um Shops, Kuriere und Bestellungen zu steuern.', + }, +}; -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('video'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Community Logistics Marketplace' - - // 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 - -
-
) - } - }; +export default function Home() { + const [language, setLanguage] = useState('en'); + const copy = useMemo(() => copyByLanguage[language] || copyByLanguage.en, [language]); return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle(copy.title)} - - -
- {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

+
+
+
+
+

LogiLocal

+

{copy.title}

- - - +
+ + + + + +
+
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+ + {copy.highlight} + +

{copy.title}

+

{copy.subtitle}

+ + + + +
+ {copy.stats.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+
-
+ +
+
+

Today's flow

+

+ Fast ordering + local delivery +

+

+ Add stores, list products, and dispatch community couriers in minutes. +

+
+
+ {copy.steps.map((step, index) => ( +
+

+ Step {index + 1} +

+

{step.title}

+

{step.description}

+
+ ))} +
+ + + + +
+
+ + +
+
+ {copy.roles.map((role) => ( +
+

{role.title}

+

{role.description}

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

{copy.ctaTitle}

+

{copy.ctaNote}

+
+ + + + +
+
+
+ + +
+
+

© 2026 LogiLocal. All rights reserved.

+
+ + Privacy Policy + + + Terms of Use + + + Admin login + +
+
+
+
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +Home.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/marketplace/quick-order.tsx b/frontend/src/pages/marketplace/quick-order.tsx new file mode 100644 index 0000000..2128e3a --- /dev/null +++ b/frontend/src/pages/marketplace/quick-order.tsx @@ -0,0 +1,340 @@ +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; +import axios from 'axios'; +import { mdiCart } from '@mdi/js'; + +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import CardBox from '../../components/CardBox'; +import FormField from '../../components/FormField'; +import BaseButton from '../../components/BaseButton'; +import BaseButtons from '../../components/BaseButtons'; +import BaseDivider from '../../components/BaseDivider'; +import { getPageTitle } from '../../config'; +import { useAppSelector } from '../../stores/hooks'; + +const formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value || 0); + +const QuickOrder = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [stores, setStores] = useState([]); + const [products, setProducts] = useState([]); + const [recentOrders, setRecentOrders] = useState([]); + const [selectedStore, setSelectedStore] = useState(''); + const [selectedProduct, setSelectedProduct] = useState(''); + const [quantity, setQuantity] = useState(1); + const [deliveryFee, setDeliveryFee] = useState(3); + const [serviceFee, setServiceFee] = useState(1); + const [customerNote, setCustomerNote] = useState(''); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [lastOrder, setLastOrder] = useState(null); + + const filteredProducts = useMemo(() => { + if (!selectedStore) return products; + return products.filter((product) => product?.store?.id === selectedStore); + }, [products, selectedStore]); + + const selectedProductData = useMemo( + () => products.find((product) => product.id === selectedProduct), + [products, selectedProduct] + ); + + const unitPrice = Number(selectedProductData?.price || 0); + const safeQuantity = Math.max(1, Number(quantity) || 1); + const subtotal = unitPrice * safeQuantity; + const taxAmount = 0; + const totalAmount = subtotal + Number(deliveryFee || 0) + Number(serviceFee || 0) + taxAmount; + + const fetchData = async () => { + setLoading(true); + setErrorMessage(''); + try { + const [storesResponse, productsResponse, ordersResponse] = await Promise.all([ + axios.get('/stores'), + axios.get('/products'), + axios.get('/orders?limit=5&page=0'), + ]); + setStores(storesResponse.data?.rows || []); + setProducts(productsResponse.data?.rows || []); + setRecentOrders(ordersResponse.data?.rows || []); + } catch (error) { + console.error('Quick order load error:', error); + setErrorMessage('Unable to load marketplace data. Please try again.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleSubmit = async () => { + setErrorMessage(''); + setSuccessMessage(''); + + if (!currentUser?.id) { + setErrorMessage('Please sign in to place an order.'); + return; + } + if (!selectedStore) { + setErrorMessage('Select a store before placing the order.'); + return; + } + if (!selectedProduct) { + setErrorMessage('Select a product to continue.'); + return; + } + + setSubmitting(true); + + try { + const orderPayload = { + order_number: `ORD-${Date.now().toString().slice(-6)}`, + order_status: 'confirmed', + fulfillment_type: 'delivery', + subtotal_amount: subtotal, + delivery_fee: deliveryFee, + service_fee: serviceFee, + discount_amount: 0, + tax_amount: taxAmount, + total_amount: totalAmount, + customer_note: customerNote || null, + placed_at: new Date().toISOString(), + customer: currentUser.id, + store: selectedStore, + }; + + const createdOrderResponse = await axios.post('/orders', { data: orderPayload }); + const createdOrder = createdOrderResponse.data; + + await axios.post('/order_items', { + data: { + order: createdOrder?.id, + product: selectedProductData?.id, + product_name_snapshot: selectedProductData?.product_name, + unit_price: unitPrice, + quantity: safeQuantity, + line_total: subtotal, + item_note: customerNote || null, + }, + }); + + setLastOrder(createdOrder); + setSuccessMessage(`Order ${createdOrder?.order_number || ''} created successfully.`); + await fetchData(); + } catch (error) { + console.error('Quick order submit error:', error); + setErrorMessage('Could not place the order. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + {getPageTitle('Quick order')} + + + + + + +
+ +
+
+

Create a delivery order

+

+ Select a store, add a product, and confirm fees to dispatch a delivery. +

+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + {successMessage && ( +
+ {successMessage} + {lastOrder?.id && ( + + )} +
+ )} + + + + + + {stores.length === 0 && !loading && ( +
+ No stores yet. Create your first store to enable ordering. +
+ +
+
+ )} + + + + + + {!filteredProducts.length && !loading && ( +
+ No products available for this store. +
+ +
+
+ )} + + + setQuantity(Number(event.target.value))} + /> + + + + setDeliveryFee(Number(event.target.value))} + /> + + + + setServiceFee(Number(event.target.value))} + /> + + + +