1
This commit is contained in:
parent
6397137989
commit
45dc3fadc9
1783
backend/src/db/seeders/20260507130000-buyer-portal-demo-data.js
Normal file
1783
backend/src/db/seeders/20260507130000-buyer-portal-demo-data.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,7 @@ const sqlRoutes = require('./routes/sql');
|
|||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
const buyerPortalRoutes = require('./routes/buyer_portal');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -122,6 +123,7 @@ app.use('/api/file', fileRoutes);
|
|||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
|
|
||||||
|
app.use('/api/buyer_portal', passport.authenticate('jwt', {session: false}), buyerPortalRoutes);
|
||||||
|
|
||||||
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
|
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
|
||||||
|
|
||||||
|
|||||||
86
backend/src/routes/buyer_portal.js
Normal file
86
backend/src/routes/buyer_portal.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const BuyerPortalService = require('../services/buyer_portal');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const workspacePermissions = [
|
||||||
|
'READ_ACCOUNTS',
|
||||||
|
'READ_LOCATIONS',
|
||||||
|
'READ_CONTACTS',
|
||||||
|
'READ_PRODUCTS',
|
||||||
|
'READ_PRICE_LISTS',
|
||||||
|
'READ_PRICE_LIST_ITEMS',
|
||||||
|
'READ_ACCOUNT_PRICE_LISTS',
|
||||||
|
'READ_ORDERS',
|
||||||
|
'READ_ORDER_ITEMS',
|
||||||
|
'READ_QUOTES',
|
||||||
|
'READ_QUOTE_ITEMS',
|
||||||
|
'READ_SAMPLE_REQUESTS',
|
||||||
|
'READ_SAVED_LISTS',
|
||||||
|
'READ_SAVED_LIST_ITEMS',
|
||||||
|
];
|
||||||
|
|
||||||
|
const createSavedListPermissions = [
|
||||||
|
'CREATE_SAVED_LISTS',
|
||||||
|
'CREATE_SAVED_LIST_ITEMS',
|
||||||
|
'READ_ACCOUNTS',
|
||||||
|
'READ_PRODUCTS',
|
||||||
|
'READ_PRICE_LIST_ITEMS',
|
||||||
|
'READ_ACCOUNT_PRICE_LISTS',
|
||||||
|
];
|
||||||
|
|
||||||
|
const createOrderPermissions = [
|
||||||
|
'CREATE_ORDERS',
|
||||||
|
'CREATE_ORDER_ITEMS',
|
||||||
|
'READ_ACCOUNTS',
|
||||||
|
'READ_LOCATIONS',
|
||||||
|
'READ_PRODUCTS',
|
||||||
|
'READ_PRICE_LIST_ITEMS',
|
||||||
|
'READ_ACCOUNT_PRICE_LISTS',
|
||||||
|
];
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/workspace',
|
||||||
|
...workspacePermissions.map((permission) => checkPermissions(permission)),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const workspace = await BuyerPortalService.workspace({
|
||||||
|
accountId: req.query.accountId,
|
||||||
|
locationId: req.query.locationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(workspace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/saved-lists',
|
||||||
|
...createSavedListPermissions.map((permission) =>
|
||||||
|
checkPermissions(permission),
|
||||||
|
),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const savedList = await BuyerPortalService.createSavedList(
|
||||||
|
req.body,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send({ savedList });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/orders',
|
||||||
|
...createOrderPermissions.map((permission) => checkPermissions(permission)),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const order = await BuyerPortalService.createOrder(
|
||||||
|
req.body,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send({ order });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
1074
backend/src/services/buyer_portal.js
Normal file
1074
backend/src/services/buyer_portal.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||||
|
|||||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal',
|
||||||
|
label: 'Buyer Portal',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_PRODUCTS'
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
1489
frontend/src/pages/buyer-portal.tsx
Normal file
1489
frontend/src/pages/buyer-portal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,166 +1,658 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import LayoutGuest from "../layouts/Guest";
|
||||||
import type { ReactElement } from 'react';
|
import { getPageTitle } from "../config";
|
||||||
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 { getPageTitle } from '../config';
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const heroImage =
|
||||||
|
"https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=2400";
|
||||||
|
|
||||||
export default function Starter() {
|
const ingredientImage =
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
"https://images.pexels.com/photos/4198018/pexels-photo-4198018.jpeg?auto=compress&cs=tinysrgb&w=1200";
|
||||||
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 = 'B2B Distributor Portal'
|
const kitchenImage =
|
||||||
|
"https://images.pexels.com/photos/4253302/pexels-photo-4253302.jpeg?auto=compress&cs=tinysrgb&w=1200";
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
const warehouseImage =
|
||||||
useEffect(() => {
|
"https://images.pexels.com/photos/4483610/pexels-photo-4483610.jpeg?auto=compress&cs=tinysrgb&w=1200";
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const deliveryImage =
|
||||||
<div
|
"https://images.pexels.com/photos/36256798/pexels-photo-36256798.jpeg?auto=compress&cs=tinysrgb&w=1200";
|
||||||
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) => {
|
const dockImage =
|
||||||
if (video?.video_files?.length > 0) {
|
"https://images.pexels.com/photos/21838827/pexels-photo-21838827.jpeg?auto=compress&cs=tinysrgb&w=1200";
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
const restaurantImage =
|
||||||
<video
|
"https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1200";
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
const catalogItems = [
|
||||||
loop
|
{
|
||||||
muted
|
sku: "NS-CHI-12X16",
|
||||||
>
|
name: "Calabrian Chili Crunch",
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
meta: "12 x 16 oz jars · Ambient",
|
||||||
Your browser does not support the video tag.
|
tag: "Chef Pantry",
|
||||||
</video>
|
price: 78,
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
moq: 3,
|
||||||
<a
|
inventory: "42 cases",
|
||||||
className='text-[8px]'
|
note: "Last ordered Apr 20",
|
||||||
href={video?.user?.url}
|
},
|
||||||
target='_blank'
|
{
|
||||||
rel='noreferrer'
|
sku: "NS-BUR-2X1K",
|
||||||
>
|
name: "Burrata di Puglia",
|
||||||
Video by {video.user.name} on Pexels
|
meta: "2 x 1 kg tubs · Refrigerated",
|
||||||
</a>
|
tag: "Cultured Dairy",
|
||||||
</div>
|
price: 62,
|
||||||
</div>)
|
moq: 2,
|
||||||
}
|
inventory: "18 cases",
|
||||||
};
|
note: "Sample-ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sku: "NS-DUC-2X5LB",
|
||||||
|
name: "Heritage Duck Confit Legs",
|
||||||
|
meta: "2 x 5 lb bags · Frozen",
|
||||||
|
tag: "Prepared Proteins",
|
||||||
|
price: 118,
|
||||||
|
moq: 1,
|
||||||
|
inventory: "9 cases",
|
||||||
|
note: "MOQ 1 case",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const imageTiles = [
|
||||||
|
{
|
||||||
|
image: ingredientImage,
|
||||||
|
label: "Chef pantry",
|
||||||
|
text: "Specialty ingredients with allergen and certification context.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: restaurantImage,
|
||||||
|
label: "Restaurant buyers",
|
||||||
|
text: "Contract ordering for dining rooms, cafés, hotels, and catering.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: dockImage,
|
||||||
|
label: "Warehouse dock",
|
||||||
|
text: "Receiving notes, delivery windows, and fulfillment handoff.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: deliveryImage,
|
||||||
|
label: "Delivery ops",
|
||||||
|
text: "Route-ready orders after buyer PO review.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const supplierModules = [
|
||||||
|
"Customer-specific price lists",
|
||||||
|
"PO checkout and order history",
|
||||||
|
"Sample requests and chef evaluations",
|
||||||
|
"Quotes, saved lists, and reorders",
|
||||||
|
"Contacts, locations, and receiving notes",
|
||||||
|
"Admin CRUD for products, accounts, inventory, and fulfillment",
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowCards = [
|
||||||
|
{
|
||||||
|
image: ingredientImage,
|
||||||
|
eyebrow: "Catalog + pricing",
|
||||||
|
title:
|
||||||
|
"Restaurant buyers see only their contracted products and case prices.",
|
||||||
|
text: "SKUs, pack sizes, allergens, certifications, MOQs, and substitution context stay visible while buyers build an order.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: kitchenImage,
|
||||||
|
eyebrow: "Samples + quotes",
|
||||||
|
title: "Sales teams can turn tastings and trade-show leads into follow-up.",
|
||||||
|
text: "Sample requests, quote records, account notes, and buyer contacts sit beside the catalog instead of disappearing into email.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: warehouseImage,
|
||||||
|
eyebrow: "Fulfillment handoff",
|
||||||
|
title: "Orders move from buyer portal into distributor operations.",
|
||||||
|
text: "Submitted POs can be reviewed in admin modules with customer, location, delivery date, payment terms, and line-level details.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: deliveryImage,
|
||||||
|
eyebrow: "Delivery readiness",
|
||||||
|
title: "Dispatch teams see buyer context before the order leaves the dock.",
|
||||||
|
text: "Location, contact, receiving notes, requested date, and order value stay attached to the purchase order.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [draftQuantities, setDraftQuantities] = React.useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({
|
||||||
|
"NS-CHI-12X16": 3,
|
||||||
|
"NS-BUR-2X1K": 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftLines = catalogItems
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
quantity: draftQuantities[item.sku] || 0,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.quantity > 0);
|
||||||
|
|
||||||
|
const draftCases = draftLines.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const draftTotal = draftLines.reduce(
|
||||||
|
(sum, item) => sum + item.quantity * item.price,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const addToDraft = (sku: string) => {
|
||||||
|
setDraftQuantities((current) => ({
|
||||||
|
...current,
|
||||||
|
[sku]: (current[sku] || 0) + 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeDraftQuantity = (sku: string, nextQuantity: number) => {
|
||||||
|
setDraftQuantities((current) => ({
|
||||||
|
...current,
|
||||||
|
[sku]: Math.max(nextQuantity, 0),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
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("Northstar Foodservice Portal")}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A B2B foodservice supplier and distributor portal for contract catalogs, buyer ordering, sample requests, quotes, and reorders."
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
<style jsx global>{`
|
||||||
|
body div[style*="z-index: 2147483647"][style*="bottom: 20px"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
nextjs-portal {
|
||||||
<div
|
display: none !important;
|
||||||
className={`flex ${
|
}
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
`}</style>
|
||||||
} min-h-screen w-full`}
|
|
||||||
>
|
<div className="min-h-screen bg-stone-50 text-slate-950">
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<header className="border-b border-stone-200 bg-stone-50/95 backdrop-blur">
|
||||||
? imageBlock(illustrationImage)
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||||
: null}
|
<a href="/" className="group block">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
? videoBlock(illustrationVideo)
|
Northstar Foodservice
|
||||||
: null}
|
</p>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<p className="mt-1 text-lg font-semibold text-slate-950">
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
Supplier / Distributor B2B Portal
|
||||||
<CardBoxComponentTitle title="Welcome to your B2B Distributor Portal app!"/>
|
</p>
|
||||||
|
</a>
|
||||||
<div className="space-y-3">
|
<nav className="flex flex-wrap items-center gap-2 text-sm font-semibold">
|
||||||
<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>
|
<a
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
href="#catalog"
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
className="rounded-full px-4 py-2 text-slate-700 hover:bg-white"
|
||||||
|
>
|
||||||
|
Contract catalog
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#operations"
|
||||||
|
className="rounded-full px-4 py-2 text-slate-700 hover:bg-white"
|
||||||
|
>
|
||||||
|
Operations
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="rounded-full border border-slate-300 bg-white px-4 py-2 text-slate-900 shadow-sm"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/buyer-portal"
|
||||||
|
className="rounded-full bg-slate-950 px-5 py-2 text-white shadow-sm"
|
||||||
|
>
|
||||||
|
Open portal
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="relative overflow-hidden border-b border-stone-200 bg-stone-100 text-slate-950">
|
||||||
|
<img
|
||||||
|
src={heroImage}
|
||||||
|
alt="Restaurant table with foodservice ingredients for contract buyers"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-stone-50 via-stone-50/95 to-stone-50/30" />
|
||||||
|
<div className="absolute inset-y-0 right-0 hidden w-1/3 bg-gradient-to-l from-stone-50/10 to-transparent lg:block" />
|
||||||
|
<div className="relative mx-auto max-w-7xl px-6 py-20">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
|
Distributor buyer portal
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-5 max-w-4xl text-5xl font-semibold leading-tight">
|
||||||
|
Northstar Foodservice Portal
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-3xl text-lg leading-8 text-slate-700">
|
||||||
|
A real B2B ordering workspace for specialty foodservice
|
||||||
|
suppliers. Restaurants, caterers, hotels, and multi-location
|
||||||
|
buyers can browse a contract catalog, request samples, place
|
||||||
|
purchase orders, and reorder from history without chasing
|
||||||
|
spreadsheets or PDF price sheets.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href="/buyer-portal"
|
||||||
|
className="rounded-full bg-emerald-400 px-6 py-3 text-sm font-bold text-slate-950 shadow-lg"
|
||||||
|
>
|
||||||
|
Open buyer portal
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="rounded-full border border-slate-300 bg-white/80 px-6 py-3 text-sm font-bold text-slate-950 shadow-sm backdrop-blur"
|
||||||
|
>
|
||||||
|
Admin workspace
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="rounded-full border border-slate-300 bg-white/55 px-6 py-3 text-sm font-bold text-slate-900 backdrop-blur"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 grid max-w-5xl gap-3 sm:grid-cols-3">
|
||||||
|
<div className="border-l-4 border-emerald-500 bg-white/85 p-4 shadow-sm backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
Buyer account
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-950">
|
||||||
|
Harbor Table
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-amber-400 bg-white/85 p-4 shadow-sm backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
Cutoff
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-950">
|
||||||
|
Today 3 PM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-sky-400 bg-white/85 p-4 shadow-sm backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
Contract catalog
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-950">
|
||||||
|
7 SKUs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<section className="border-b border-stone-200 bg-white px-6 py-12">
|
||||||
</CardBox>
|
<div className="mx-auto max-w-7xl">
|
||||||
</div>
|
<div className="mb-7 flex flex-wrap items-end justify-between gap-4">
|
||||||
</div>
|
<div>
|
||||||
</SectionFullScreen>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
Portal coverage
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
</p>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<h2 className="mt-3 text-3xl font-semibold text-slate-950">
|
||||||
Privacy Policy
|
From chef demand to dock handoff
|
||||||
</Link>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="max-w-xl text-sm leading-7 text-slate-600">
|
||||||
|
The template should feel like a supplier operating system, not
|
||||||
|
a generic SaaS shell. These are the buyer and distributor
|
||||||
|
moments the demo now foregrounds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 md:grid-cols-4">
|
||||||
|
{imageTiles.map((tile) => (
|
||||||
|
<article
|
||||||
|
key={tile.label}
|
||||||
|
className="group overflow-hidden border border-stone-200 bg-stone-50 shadow-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={tile.image}
|
||||||
|
alt={tile.label}
|
||||||
|
className="h-44 w-full object-cover transition duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700">
|
||||||
|
{tile.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
{tile.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
<section id="catalog" className="mx-auto max-w-7xl px-6 py-12">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_390px]">
|
||||||
|
<div className="border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-stone-200 pb-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
|
Live buying workspace
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold">
|
||||||
|
Contract catalog for replenishment
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-2xl text-base leading-7 text-slate-600">
|
||||||
|
This is the screen buyers expect: account context, ship-to
|
||||||
|
location, product search, contracted prices, MOQs, and
|
||||||
|
reorder hints in one dense procurement view. The preview
|
||||||
|
below is interactive; adding items updates the draft PO on
|
||||||
|
this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-[220px] gap-2 text-sm">
|
||||||
|
<div className="flex justify-between rounded-xl bg-stone-100 px-4 py-3">
|
||||||
|
<span className="text-slate-500">Price file</span>
|
||||||
|
<span className="font-semibold">Northstar Contract</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between rounded-xl bg-stone-100 px-4 py-3">
|
||||||
|
<span className="text-slate-500">Ship-to</span>
|
||||||
|
<span className="font-semibold">Downtown Dock A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px_150px]">
|
||||||
|
<div className="rounded-xl border border-stone-300 bg-stone-50 px-4 py-3 text-slate-500">
|
||||||
|
Search SKU, ingredient, allergen
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stone-300 bg-white px-4 py-3 font-semibold">
|
||||||
|
All categories
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stone-300 bg-white px-4 py-3 font-semibold">
|
||||||
|
Orderable
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{catalogItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.sku}
|
||||||
|
className="grid gap-4 rounded-2xl border border-stone-200 bg-stone-50 p-4 md:grid-cols-[minmax(0,1fr)_160px_140px] md:items-center"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full bg-slate-950 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
{item.sku}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
|
||||||
|
{item.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-xl font-semibold">
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
{item.meta}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
Contract case
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">
|
||||||
|
{formatCurrency(item.price)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-slate-500">
|
||||||
|
MOQ {item.moq} · {item.inventory}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="text-sm font-semibold text-slate-700">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToDraft(item.sku)}
|
||||||
|
className="rounded-xl bg-blue-600 px-4 py-3 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Add to demo PO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-6">
|
||||||
|
<div className="border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-blue-700">
|
||||||
|
Draft purchase order
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold">
|
||||||
|
Interactive PO preview
|
||||||
|
</h2>
|
||||||
|
<div className="mt-5 grid gap-3 rounded-2xl bg-slate-950 p-5 text-white">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-300">PO</span>
|
||||||
|
<span className="font-semibold">HT-PO-4901</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-300">Delivery</span>
|
||||||
|
<span className="font-semibold">May 11, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-300">Lines</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{draftLines.length} SKU
|
||||||
|
{draftLines.length === 1 ? "" : "s"} · {draftCases} case
|
||||||
|
{draftCases === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-white/15 pt-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-300">
|
||||||
|
Draft total
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold">
|
||||||
|
{formatCurrency(draftTotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{draftLines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={line.sku}
|
||||||
|
className="rounded-2xl border border-stone-200 bg-stone-50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
{line.sku}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-semibold text-slate-950">
|
||||||
|
{line.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-slate-950">
|
||||||
|
{formatCurrency(line.price * line.quantity)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{line.quantity} case
|
||||||
|
{line.quantity === 1 ? "" : "s"} ·{" "}
|
||||||
|
{formatCurrency(line.price)} each
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center rounded-full border border-stone-300 bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
changeDraftQuantity(line.sku, line.quantity - 1)
|
||||||
|
}
|
||||||
|
className="h-9 w-9 rounded-full text-lg font-semibold text-slate-700"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="min-w-8 text-center text-sm font-semibold">
|
||||||
|
{line.quantity}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
changeDraftQuantity(line.sku, line.quantity + 1)
|
||||||
|
}
|
||||||
|
className="h-9 w-9 rounded-full text-lg font-semibold text-slate-700"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm leading-7 text-amber-900">
|
||||||
|
Checkout review flags PO, delivery location, MOQ, and item
|
||||||
|
availability before the order reaches the distributor team.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/buyer-portal"
|
||||||
|
className="mt-4 block rounded-xl bg-emerald-500 px-5 py-4 text-center text-sm font-bold text-slate-950"
|
||||||
|
>
|
||||||
|
Continue in real buyer portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden border border-stone-200 bg-white shadow-sm">
|
||||||
|
<img
|
||||||
|
src={ingredientImage}
|
||||||
|
alt="Chef ingredients for a foodservice contract catalog"
|
||||||
|
className="h-48 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
|
Sample-ready
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-2xl font-semibold">
|
||||||
|
Chef evaluation flow
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-600">
|
||||||
|
Buyers can request tasting samples for new menu items,
|
||||||
|
while sales reps keep the follow-up tied to account,
|
||||||
|
product, quote, and order history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="operations"
|
||||||
|
className="border-y border-stone-200 bg-white"
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-12">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-blue-700">
|
||||||
|
Supplier operations
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold">
|
||||||
|
Not just a landing page: the portal maps to real distributor
|
||||||
|
work.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-base leading-8 text-slate-600">
|
||||||
|
The public entry point now frames the app as a vertical
|
||||||
|
template. Behind it, the buyer portal and admin modules cover
|
||||||
|
the core B2B supplier path from catalog discovery to order
|
||||||
|
management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-6 lg:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{workflowCards.map((card) => (
|
||||||
|
<article
|
||||||
|
key={card.title}
|
||||||
|
className="overflow-hidden border border-stone-200 bg-stone-50"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={card.image}
|
||||||
|
alt={card.title}
|
||||||
|
className="h-56 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700">
|
||||||
|
{card.eyebrow}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-3 text-xl font-semibold">
|
||||||
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-600">
|
||||||
|
{card.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{supplierModules.map((module) => (
|
||||||
|
<div
|
||||||
|
key={module}
|
||||||
|
className="rounded-2xl border border-stone-200 bg-stone-50 px-5 py-4 text-sm font-semibold text-slate-800"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-7xl px-6 py-12">
|
||||||
|
<div className="grid gap-6 bg-slate-950 p-6 text-white lg:grid-cols-[minmax(0,1fr)_360px] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-300">
|
||||||
|
Try the vertical slice
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold">
|
||||||
|
Open the buyer portal, then inspect the generated admin
|
||||||
|
modules.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-base leading-8 text-slate-300">
|
||||||
|
This gives us a believable first version for the restaurant
|
||||||
|
and foodservice conference story: a supplier portal that
|
||||||
|
buyers understand immediately and operators can extend with
|
||||||
|
integrations later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<a
|
||||||
|
href="/buyer-portal"
|
||||||
|
className="rounded-xl bg-emerald-400 px-5 py-4 text-center text-sm font-bold text-slate-950"
|
||||||
|
>
|
||||||
|
Open buyer portal
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="rounded-xl border border-white/20 px-5 py-4 text-center text-sm font-bold text-white"
|
||||||
|
>
|
||||||
|
Admin dashboard
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/products/products-list"
|
||||||
|
className="rounded-xl border border-white/20 px-5 py-4 text-center text-sm font-bold text-white"
|
||||||
|
>
|
||||||
|
Product admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user