Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547bf79d10 | ||
|
|
c15f7371d4 | ||
|
|
b0b2dc530c |
16419
backend/package-lock.json
generated
Normal file
16419
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -31,9 +31,9 @@
|
|||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-microsoft": "^0.1.0",
|
"passport-microsoft": "^0.1.0",
|
||||||
"pg": "8.4.1",
|
"pg": "^8.4.1",
|
||||||
"pg-hstore": "2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"sequelize": "6.35.2",
|
"sequelize": "^6.35.2",
|
||||||
"sequelize-json-schema": "^2.1.1",
|
"sequelize-json-schema": "^2.1.1",
|
||||||
"sqlite": "4.0.15",
|
"sqlite": "4.0.15",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
|||||||
@ -461,12 +461,12 @@ module.exports = class Courier_profilesDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'verification_files',
|
as: 'verification_files',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'profile_images',
|
as: 'profile_images',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ module.exports = class FileDBApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const file of inexistentFiles) {
|
for (const file of inexistentFiles) {
|
||||||
await db.file.create(
|
await db.files.create(
|
||||||
{
|
{
|
||||||
belongsTo: relation.belongsTo,
|
belongsTo: relation.belongsTo,
|
||||||
belongsToColumn: relation.belongsToColumn,
|
belongsToColumn: relation.belongsToColumn,
|
||||||
@ -62,7 +62,7 @@ module.exports = class FileDBApi {
|
|||||||
) {
|
) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const filesToDelete = await db.file.findAll({
|
const filesToDelete = await db.files.findAll({
|
||||||
where: {
|
where: {
|
||||||
belongsTo: relation.belongsTo,
|
belongsTo: relation.belongsTo,
|
||||||
belongsToId: relation.belongsToId,
|
belongsToId: relation.belongsToId,
|
||||||
|
|||||||
@ -456,12 +456,12 @@ module.exports = class Merchant_profilesDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'logo_images',
|
as: 'logo_images',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'banner_images',
|
as: 'banner_images',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -367,7 +367,7 @@ module.exports = class Product_categoriesDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'category_images',
|
as: 'category_images',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -460,7 +460,7 @@ module.exports = class ProductsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'product_images',
|
as: 'product_images',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -498,7 +498,7 @@ module.exports = class StoresDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'store_images',
|
as: 'store_images',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -448,7 +448,7 @@ module.exports = class Support_ticketsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'attachment_files',
|
as: 'attachment_files',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -577,7 +577,7 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.file,
|
model: db.files,
|
||||||
as: 'avatar',
|
as: 'avatar',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -163,7 +163,7 @@ rating_count: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.courier_profiles.hasMany(db.file, {
|
db.courier_profiles.hasMany(db.files, {
|
||||||
as: 'verification_files',
|
as: 'verification_files',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
@ -173,7 +173,7 @@ rating_count: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
db.courier_profiles.hasMany(db.file, {
|
db.courier_profiles.hasMany(db.files, {
|
||||||
as: 'profile_images',
|
as: 'profile_images',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const file = sequelize.define(
|
const file = sequelize.define(
|
||||||
'file',
|
'files',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
@ -40,11 +40,11 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
file.associate = (db) => {
|
file.associate = (db) => {
|
||||||
db.file.belongsTo(db.users, {
|
db.files.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
|
|
||||||
db.file.belongsTo(db.users, {
|
db.files.belongsTo(db.users, {
|
||||||
as: 'updatedBy',
|
as: 'updatedBy',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -171,7 +171,7 @@ status: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.merchant_profiles.hasMany(db.file, {
|
db.merchant_profiles.hasMany(db.files, {
|
||||||
as: 'logo_images',
|
as: 'logo_images',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
@ -181,7 +181,7 @@ status: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
db.merchant_profiles.hasMany(db.file, {
|
db.merchant_profiles.hasMany(db.files, {
|
||||||
as: 'banner_images',
|
as: 'banner_images',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -119,7 +119,7 @@ is_active: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.product_categories.hasMany(db.file, {
|
db.product_categories.hasMany(db.files, {
|
||||||
as: 'category_images',
|
as: 'category_images',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -166,7 +166,7 @@ is_featured: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.products.hasMany(db.file, {
|
db.products.hasMany(db.files, {
|
||||||
as: 'product_images',
|
as: 'product_images',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -219,7 +219,7 @@ closed_at: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.stores.hasMany(db.file, {
|
db.stores.hasMany(db.files, {
|
||||||
as: 'store_images',
|
as: 'store_images',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -195,7 +195,7 @@ resolved_at: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.support_tickets.hasMany(db.file, {
|
db.support_tickets.hasMany(db.files, {
|
||||||
as: 'attachment_files',
|
as: 'attachment_files',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -276,7 +276,7 @@ provider: {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.users.hasMany(db.file, {
|
db.users.hasMany(db.files, {
|
||||||
as: 'avatar',
|
as: 'avatar',
|
||||||
foreignKey: 'belongsToId',
|
foreignKey: 'belongsToId',
|
||||||
constraints: false,
|
constraints: false,
|
||||||
|
|||||||
@ -102,9 +102,8 @@ router.use(checkCrudPermissions('orders'));
|
|||||||
router.post('/', wrapAsync(async (req, res) => {
|
router.post('/', wrapAsync(async (req, res) => {
|
||||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
const link = new URL(referer);
|
const link = new URL(referer);
|
||||||
await OrdersService.create(req.body.data, req.currentUser, true, link.host);
|
const createdOrder = await OrdersService.create(req.body.data, req.currentUser, true, link.host);
|
||||||
const payload = true;
|
res.status(200).send(createdOrder);
|
||||||
res.status(200).send(payload);
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -15,7 +15,7 @@ module.exports = class OrdersService {
|
|||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await OrdersDBApi.create(
|
const createdOrder = await OrdersDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -24,6 +24,7 @@ module.exports = class OrdersService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return createdOrder;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -135,4 +136,3 @@ module.exports = class OrdersService {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2799
backend/yarn.lock
2799
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
52
frontend/public/locales/en-US/common.json
Normal file
52
frontend/public/locales/en-US/common.json
Normal 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": "Don’t 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,11 @@ import Select, { components, SingleValueProps, OptionProps } from 'react-select'
|
|||||||
|
|
||||||
type LanguageOption = { label: string; value: string };
|
type LanguageOption = { label: string; value: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const LANGS: LanguageOption[] = [
|
const LANGS: LanguageOption[] = [
|
||||||
{ value: 'en', label: '🇬🇧 EN' },
|
{ value: 'en', label: '🇬🇧 EN' },
|
||||||
{ value: 'fr', label: '🇫🇷 FR' },
|
{ value: 'fr', label: '🇫🇷 FR' },
|
||||||
@ -22,7 +27,7 @@ const SingleVal = (props: SingleValueProps<LanguageOption, false>) => (
|
|||||||
</components.SingleValue>
|
</components.SingleValue>
|
||||||
);
|
);
|
||||||
|
|
||||||
const LanguageSwitcher: React.FC = () => {
|
const LanguageSwitcher: React.FC<Props> = ({ value, onChange }) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [selected, setSelected] = useState<LanguageOption>(LANGS[0]);
|
const [selected, setSelected] = useState<LanguageOption>(LANGS[0]);
|
||||||
|
|
||||||
@ -30,9 +35,20 @@ const LanguageSwitcher: React.FC = () => {
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) return;
|
||||||
|
const nextSelection = LANGS.find((option) => option.value === value);
|
||||||
|
if (nextSelection) {
|
||||||
|
setSelected(nextSelection);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const handleChange = (opt: LanguageOption | null) => {
|
const handleChange = (opt: LanguageOption | null) => {
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
setSelected(opt);
|
setSelected(opt);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(opt.value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, {useEffect, useRef, useState} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -112,6 +112,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: 'mdiReceiptText' in icon ? icon['mdiReceiptText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: 'mdiReceiptText' in icon ? icon['mdiReceiptText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_ORDERS'
|
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',
|
href: '/order_items/order_items-list',
|
||||||
label: 'Order items',
|
label: 'Order items',
|
||||||
|
|||||||
@ -1,166 +1,340 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
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 BaseButtons from '../components/BaseButtons';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import LayoutGuest from '../layouts/Guest';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
import { getPageTitle } from '../config';
|
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() {
|
export default function Home() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
const [language, setLanguage] = useState('en');
|
||||||
src: undefined,
|
const copy = useMemo(() => copyByLanguage[language] || copyByLanguage.en, [language]);
|
||||||
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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle(copy.title)}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
<div className="min-h-screen bg-slate-950 text-white">
|
||||||
<SectionFullScreen bg='violet'>
|
<header className="border-b border-white/10 bg-slate-950/60 backdrop-blur">
|
||||||
<div
|
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
|
||||||
className={`flex ${
|
<div>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<p className="text-sm uppercase tracking-[0.3em] text-emerald-300">LogiLocal</p>
|
||||||
} min-h-screen w-full`}
|
<h1 className="text-xl font-semibold">{copy.title}</h1>
|
||||||
>
|
|
||||||
{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>
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<BaseButtons>
|
<LanguageSwitcher value={language} onChange={setLanguage} />
|
||||||
<BaseButton
|
<BaseButtons type="justify-end" className="hidden md:flex" mb="mb-0">
|
||||||
href='/login'
|
<BaseButton href="/login" label="Login" color="white" outline />
|
||||||
label='Login'
|
<BaseButton href="/login" label={copy.ctaSecondary} color="info" />
|
||||||
color='info'
|
</BaseButtons>
|
||||||
className='w-full'
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
</BaseButtons>
|
<main>
|
||||||
</CardBox>
|
<section className="mx-auto grid w-full max-w-6xl gap-10 px-6 py-16 lg:grid-cols-2 lg:items-center">
|
||||||
</div>
|
<div className="space-y-6">
|
||||||
</div>
|
<span className="inline-flex w-fit items-center rounded-full bg-emerald-400/15 px-4 py-1 text-sm text-emerald-200">
|
||||||
</SectionFullScreen>
|
{copy.highlight}
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
</span>
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
<h2 className="text-4xl font-semibold leading-tight lg:text-5xl">{copy.title}</h2>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<p className="text-lg text-slate-200">{copy.subtitle}</p>
|
||||||
Privacy Policy
|
<BaseButtons type="justify-start" mb="mb-0">
|
||||||
</Link>
|
<BaseButton href="/register" label={copy.ctaPrimary} color="success" />
|
||||||
</div>
|
<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'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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
340
frontend/src/pages/marketplace/quick-order.tsx
Normal file
340
frontend/src/pages/marketplace/quick-order.tsx
Normal 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;
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user