Compare commits

...

3 Commits

Author SHA1 Message Date
Flatlogic Bot
547bf79d10 Autosave: 20260311-221453 2026-03-11 22:15:31 +00:00
Flatlogic Bot
c15f7371d4 1.0.0.1 2026-03-11 22:01:23 +00:00
Flatlogic Bot
b0b2dc530c 1.0.0.0 2026-03-11 18:35:15 +00:00
31 changed files with 19127 additions and 1049 deletions

16419
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -31,9 +31,9 @@
"passport-google-oauth2": "^0.2.0",
"passport-jwt": "^4.0.1",
"passport-microsoft": "^0.1.0",
"pg": "8.4.1",
"pg-hstore": "2.3.4",
"sequelize": "6.35.2",
"pg": "^8.4.1",
"pg-hstore": "^2.3.4",
"sequelize": "^6.35.2",
"sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15",
"swagger-jsdoc": "^6.2.8",

View File

@ -461,12 +461,12 @@ module.exports = class Courier_profilesDBApi {
{
model: db.file,
model: db.files,
as: 'verification_files',
},
{
model: db.file,
model: db.files,
as: 'profile_images',
},

View File

@ -36,7 +36,7 @@ module.exports = class FileDBApi {
);
for (const file of inexistentFiles) {
await db.file.create(
await db.files.create(
{
belongsTo: relation.belongsTo,
belongsToColumn: relation.belongsToColumn,
@ -62,7 +62,7 @@ module.exports = class FileDBApi {
) {
const transaction = (options && options.transaction) || undefined;
const filesToDelete = await db.file.findAll({
const filesToDelete = await db.files.findAll({
where: {
belongsTo: relation.belongsTo,
belongsToId: relation.belongsToId,

View File

@ -456,12 +456,12 @@ module.exports = class Merchant_profilesDBApi {
{
model: db.file,
model: db.files,
as: 'logo_images',
},
{
model: db.file,
model: db.files,
as: 'banner_images',
},

View File

@ -367,7 +367,7 @@ module.exports = class Product_categoriesDBApi {
{
model: db.file,
model: db.files,
as: 'category_images',
},

View File

@ -460,7 +460,7 @@ module.exports = class ProductsDBApi {
{
model: db.file,
model: db.files,
as: 'product_images',
},

View File

@ -498,7 +498,7 @@ module.exports = class StoresDBApi {
{
model: db.file,
model: db.files,
as: 'store_images',
},

View File

@ -448,7 +448,7 @@ module.exports = class Support_ticketsDBApi {
{
model: db.file,
model: db.files,
as: 'attachment_files',
},

View File

@ -577,7 +577,7 @@ module.exports = class UsersDBApi {
{
model: db.file,
model: db.files,
as: 'avatar',
},

View File

@ -163,7 +163,7 @@ rating_count: {
db.courier_profiles.hasMany(db.file, {
db.courier_profiles.hasMany(db.files, {
as: 'verification_files',
foreignKey: 'belongsToId',
constraints: false,
@ -173,7 +173,7 @@ rating_count: {
},
});
db.courier_profiles.hasMany(db.file, {
db.courier_profiles.hasMany(db.files, {
as: 'profile_images',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -1,6 +1,6 @@
module.exports = function(sequelize, DataTypes) {
const file = sequelize.define(
'file',
'files',
{
id: {
type: DataTypes.UUID,
@ -40,11 +40,11 @@ module.exports = function(sequelize, DataTypes) {
);
file.associate = (db) => {
db.file.belongsTo(db.users, {
db.files.belongsTo(db.users, {
as: 'createdBy',
});
db.file.belongsTo(db.users, {
db.files.belongsTo(db.users, {
as: 'updatedBy',
});
};

View File

@ -171,7 +171,7 @@ status: {
db.merchant_profiles.hasMany(db.file, {
db.merchant_profiles.hasMany(db.files, {
as: 'logo_images',
foreignKey: 'belongsToId',
constraints: false,
@ -181,7 +181,7 @@ status: {
},
});
db.merchant_profiles.hasMany(db.file, {
db.merchant_profiles.hasMany(db.files, {
as: 'banner_images',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -119,7 +119,7 @@ is_active: {
db.product_categories.hasMany(db.file, {
db.product_categories.hasMany(db.files, {
as: 'category_images',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -166,7 +166,7 @@ is_featured: {
db.products.hasMany(db.file, {
db.products.hasMany(db.files, {
as: 'product_images',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -219,7 +219,7 @@ closed_at: {
db.stores.hasMany(db.file, {
db.stores.hasMany(db.files, {
as: 'store_images',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -195,7 +195,7 @@ resolved_at: {
db.support_tickets.hasMany(db.file, {
db.support_tickets.hasMany(db.files, {
as: 'attachment_files',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -276,7 +276,7 @@ provider: {
db.users.hasMany(db.file, {
db.users.hasMany(db.files, {
as: 'avatar',
foreignKey: 'belongsToId',
constraints: false,

View File

@ -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);
}));
/**

View File

@ -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 {
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,52 @@
{
"pages": {
"dashboard": {
"pageTitle": "Dashboard",
"overview": "Overview",
"loadingWidgets": "Loading widgets...",
"loading": "Loading..."
},
"login": {
"pageTitle": "Login",
"form": {
"loginLabel": "Login",
"loginHelp": "Please enter your login",
"passwordLabel": "Password",
"passwordHelp": "Please enter your password",
"remember": "Remember",
"forgotPassword": "Forgot password?",
"loginButton": "Login",
"loading": "Loading...",
"noAccountYet": "Dont have an account yet?",
"newAccount": "New Account"
},
"pexels": {
"photoCredit": "Photo by {{photographer}} on Pexels",
"videoCredit": "Video by {{name}} on Pexels",
"videoUnsupported": "Your browser does not support the video tag."
},
"footer": {
"copyright": "© {{year}} {{title}}. All rights reserved",
"privacy": "Privacy Policy"
}
}
},
"components": {
"widgetCreator": {
"title": "Create Chart or Widget",
"helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"",
"settingsTitle": "Widget Creator Settings",
"settingsDescription": "What role are we showing and creating widgets for?",
"doneButton": "Done",
"loading": "Loading..."
},
"search": {
"placeholder": "Search",
"required": "Required",
"minLength": "Minimum length: {{count}} characters"
}
}
}

View File

@ -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';

View File

@ -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<LanguageOption, false>) => (
</components.SingleValue>
);
const LanguageSwitcher: React.FC = () => {
const LanguageSwitcher: React.FC<Props> = ({ value, onChange }) => {
const [mounted, setMounted] = useState(false);
const [selected, setSelected] = useState<LanguageOption>(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;

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
export default function Home() {
const [language, setLanguage] = useState('en');
const copy = useMemo(() => copyByLanguage[language] || copyByLanguage.en, [language]);
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle(copy.title)}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Community Logistics Marketplace app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="min-h-screen bg-slate-950 text-white">
<header className="border-b border-white/10 bg-slate-950/60 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
<div>
<p className="text-sm uppercase tracking-[0.3em] text-emerald-300">LogiLocal</p>
<h1 className="text-xl font-semibold">{copy.title}</h1>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<div className="flex items-center gap-4">
<LanguageSwitcher value={language} onChange={setLanguage} />
<BaseButtons type="justify-end" className="hidden md:flex" mb="mb-0">
<BaseButton href="/login" label="Login" color="white" outline />
<BaseButton href="/login" label={copy.ctaSecondary} color="info" />
</BaseButtons>
</div>
</div>
</header>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<main>
<section className="mx-auto grid w-full max-w-6xl gap-10 px-6 py-16 lg:grid-cols-2 lg:items-center">
<div className="space-y-6">
<span className="inline-flex w-fit items-center rounded-full bg-emerald-400/15 px-4 py-1 text-sm text-emerald-200">
{copy.highlight}
</span>
<h2 className="text-4xl font-semibold leading-tight lg:text-5xl">{copy.title}</h2>
<p className="text-lg text-slate-200">{copy.subtitle}</p>
<BaseButtons type="justify-start" mb="mb-0">
<BaseButton href="/register" label={copy.ctaPrimary} color="success" />
<BaseButton href="/login" label="Login" color="white" outline />
</BaseButtons>
<div className="grid gap-4 pt-6 md:grid-cols-3">
{copy.stats.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-sm text-slate-300">{item.label}</p>
<p className="text-base font-semibold text-white">{item.value}</p>
</div>
))}
</div>
</div>
</div>
<CardBox className="border border-white/10 bg-white/5">
<div className="space-y-5">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-emerald-200">Today&apos;s flow</p>
<h3 className="text-2xl font-semibold text-white">
Fast ordering + local delivery
</h3>
<p className="text-sm text-slate-200">
Add stores, list products, and dispatch community couriers in minutes.
</p>
</div>
<div className="space-y-3">
{copy.steps.map((step, index) => (
<div key={step.title} className="rounded-xl border border-white/10 bg-slate-900/70 p-4">
<p className="text-xs uppercase tracking-[0.25em] text-emerald-300">
Step {index + 1}
</p>
<p className="text-base font-semibold text-white">{step.title}</p>
<p className="text-sm text-slate-300">{step.description}</p>
</div>
))}
</div>
<BaseButtons type="justify-start" mb="mb-0">
<BaseButton href="/login" label={copy.ctaSecondary} color="info" />
<BaseButton href="/dashboard" label="Go to dashboard" color="white" outline />
</BaseButtons>
</div>
</CardBox>
</section>
<section className="mx-auto w-full max-w-6xl px-6 pb-12">
<div className="grid gap-6 md:grid-cols-3">
{copy.roles.map((role) => (
<div key={role.title} className="rounded-3xl border border-white/10 bg-white/5 p-6">
<h3 className="text-lg font-semibold text-white">{role.title}</h3>
<p className="mt-3 text-sm text-slate-200">{role.description}</p>
</div>
))}
</div>
</section>
<section className="mx-auto w-full max-w-6xl px-6 pb-20">
<div className="rounded-3xl border border-emerald-400/20 bg-gradient-to-r from-emerald-500/10 via-slate-900/70 to-blue-500/10 p-8">
<div className="grid gap-6 lg:grid-cols-[1.6fr_1fr] lg:items-center">
<div>
<h3 className="text-2xl font-semibold text-white">{copy.ctaTitle}</h3>
<p className="mt-2 text-sm text-slate-200">{copy.ctaNote}</p>
</div>
<BaseButtons type="justify-start" mb="mb-0">
<BaseButton href="/register" label={copy.ctaPrimary} color="success" />
<BaseButton href="/login" label={copy.ctaSecondary} color="white" outline />
</BaseButtons>
</div>
</div>
</section>
</main>
<footer className="border-t border-white/10 bg-slate-950/70">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-4 px-6 py-6 text-sm text-slate-300 md:flex-row">
<p>© 2026 LogiLocal. All rights reserved.</p>
<div className="flex items-center gap-4">
<Link className="hover:text-white" href="/privacy-policy/">
Privacy Policy
</Link>
<Link className="hover:text-white" href="/terms-of-use/">
Terms of Use
</Link>
<Link className="hover:text-white" href="/login">
Admin login
</Link>
</div>
</div>
</footer>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
Home.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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<any[]>([]);
const [products, setProducts] = useState<any[]>([]);
const [recentOrders, setRecentOrders] = useState<any[]>([]);
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<any | null>(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 (
<>
<Head>
<title>{getPageTitle('Quick order')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCart} title="Quick order" main>
<BaseButton href="/orders/orders-list" label="Orders list" color="info" />
</SectionTitleLineWithButton>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<CardBox className="xl:col-span-2">
<div className="space-y-4">
<div>
<h2 className="text-xl font-semibold">Create a delivery order</h2>
<p className="text-sm text-gray-500">
Select a store, add a product, and confirm fees to dispatch a delivery.
</p>
</div>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{errorMessage}
</div>
)}
{successMessage && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{successMessage}
{lastOrder?.id && (
<LinkToOrder orderId={lastOrder.id} />
)}
</div>
)}
<FormField label="Store">
<select value={selectedStore} onChange={(event) => setSelectedStore(event.target.value)}>
<option value="">Select a store</option>
{stores.map((store) => (
<option key={store.id} value={store.id}>
{store.store_name || 'Unnamed store'}
</option>
))}
</select>
</FormField>
{stores.length === 0 && !loading && (
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500">
No stores yet. Create your first store to enable ordering.
<div className="mt-2">
<BaseButton href="/stores/stores-new" label="Create store" color="info" />
</div>
</div>
)}
<FormField label="Product">
<select
value={selectedProduct}
onChange={(event) => setSelectedProduct(event.target.value)}
disabled={!filteredProducts.length}
>
<option value="">Select a product</option>
{filteredProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.product_name || 'Unnamed product'}
</option>
))}
</select>
</FormField>
{!filteredProducts.length && !loading && (
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500">
No products available for this store.
<div className="mt-2">
<BaseButton href="/products/products-new" label="Add product" color="info" />
</div>
</div>
)}
<FormField label="Quantity">
<input
type="number"
min="1"
value={safeQuantity}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
</FormField>
<FormField label="Delivery fee">
<input
type="number"
min="0"
step="0.5"
value={deliveryFee}
onChange={(event) => setDeliveryFee(Number(event.target.value))}
/>
</FormField>
<FormField label="Service fee">
<input
type="number"
min="0"
step="0.5"
value={serviceFee}
onChange={(event) => setServiceFee(Number(event.target.value))}
/>
</FormField>
<FormField label="Customer note" hasTextareaHeight>
<textarea
value={customerNote}
onChange={(event) => setCustomerNote(event.target.value)}
placeholder="Delivery notes, landmarks, preferred time"
/>
</FormField>
<BaseDivider />
<BaseButtons type="justify-start" mb="mb-0">
<BaseButton
label={submitting ? 'Placing order...' : 'Place order'}
color="success"
onClick={handleSubmit}
disabled={submitting || loading}
/>
<BaseButton href="/orders/orders-new" label="Advanced order form" color="white" outline />
</BaseButtons>
</div>
</CardBox>
<div className="space-y-6">
<CardBox>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Order summary</h3>
<div className="text-sm text-gray-500">
<p>Product: {selectedProductData?.product_name || 'Select a product'}</p>
<p>Store: {stores.find((store) => store.id === selectedStore)?.store_name || 'Select a store'}</p>
</div>
<div className="space-y-2 text-sm">
<SummaryRow label="Unit price" value={formatCurrency(unitPrice)} />
<SummaryRow label="Quantity" value={safeQuantity.toString()} />
<SummaryRow label="Subtotal" value={formatCurrency(subtotal)} />
<SummaryRow label="Delivery fee" value={formatCurrency(Number(deliveryFee || 0))} />
<SummaryRow label="Service fee" value={formatCurrency(Number(serviceFee || 0))} />
<SummaryRow label="Total" value={formatCurrency(totalAmount)} strong />
</div>
</div>
</CardBox>
<CardBox>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Recent orders</h3>
<BaseButton href="/orders/orders-list" label="View all" color="white" outline small />
</div>
{recentOrders.length === 0 && !loading && (
<p className="text-sm text-gray-500">No orders yet. Create the first one.</p>
)}
<div className="space-y-3">
{recentOrders.map((order) => (
<div key={order.id} className="rounded-lg border border-gray-200 px-3 py-2">
<p className="text-sm font-semibold">{order.order_number || 'Order'}</p>
<p className="text-xs text-gray-500">
{order.store?.store_name || 'Store'} · {formatCurrency(Number(order.total_amount || 0))}
</p>
<div className="mt-2">
<BaseButton
href={`/orders/orders-view?id=${order.id}`}
label="View"
color="info"
small
/>
</div>
</div>
))}
</div>
</div>
</CardBox>
</div>
</div>
</SectionMain>
</>
);
};
const SummaryRow = ({ label, value, strong = false }: { label: string; value: string; strong?: boolean }) => (
<div className="flex items-center justify-between">
<span className="text-gray-500">{label}</span>
<span className={strong ? 'font-semibold text-gray-900' : 'text-gray-700'}>{value}</span>
</div>
);
const LinkToOrder = ({ orderId }: { orderId: string }) => (
<div className="mt-2">
<BaseButton href={`/orders/orders-view?id=${orderId}`} label="View order" color="success" small />
</div>
);
QuickOrder.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default QuickOrder;

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';

BIN
zitG755V Normal file

Binary file not shown.