This commit is contained in:
Flatlogic Bot 2026-05-07 13:54:04 +00:00
parent 6397137989
commit 45dc3fadc9
9 changed files with 5082 additions and 150 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
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.enable('trust proxy');
app.use('/api/buyer_portal', passport.authenticate('jwt', {session: false}), buyerPortalRoutes);
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);

View 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;

File diff suppressed because it is too large Load Diff

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

@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
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',

File diff suppressed because it is too large Load Diff

View File

@ -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 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 { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import LayoutGuest from "../layouts/Guest";
import { getPageTitle } from "../config";
const heroImage =
"https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=2400";
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 ingredientImage =
"https://images.pexels.com/photos/4198018/pexels-photo-4198018.jpeg?auto=compress&cs=tinysrgb&w=1200";
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
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const warehouseImage =
"https://images.pexels.com/photos/4483610/pexels-photo-4483610.jpeg?auto=compress&cs=tinysrgb&w=1200";
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 deliveryImage =
"https://images.pexels.com/photos/36256798/pexels-photo-36256798.jpeg?auto=compress&cs=tinysrgb&w=1200";
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>)
}
};
const dockImage =
"https://images.pexels.com/photos/21838827/pexels-photo-21838827.jpeg?auto=compress&cs=tinysrgb&w=1200";
const restaurantImage =
"https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1200";
const catalogItems = [
{
sku: "NS-CHI-12X16",
name: "Calabrian Chili Crunch",
meta: "12 x 16 oz jars · Ambient",
tag: "Chef Pantry",
price: 78,
moq: 3,
inventory: "42 cases",
note: "Last ordered Apr 20",
},
{
sku: "NS-BUR-2X1K",
name: "Burrata di Puglia",
meta: "2 x 1 kg tubs · Refrigerated",
tag: "Cultured Dairy",
price: 62,
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 (
<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("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>
<style jsx global>{`
body div[style*="z-index: 2147483647"][style*="bottom: 20px"] {
display: none !important;
}
<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 B2B Distributor Portal 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>
nextjs-portal {
display: none !important;
}
`}</style>
<div className="min-h-screen bg-stone-50 text-slate-950">
<header className="border-b border-stone-200 bg-stone-50/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-4">
<a href="/" className="group block">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700">
Northstar Foodservice
</p>
<p className="mt-1 text-lg font-semibold text-slate-950">
Supplier / Distributor B2B Portal
</p>
</a>
<nav className="flex flex-wrap items-center gap-2 text-sm font-semibold">
<a
href="#catalog"
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>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</section>
</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>
<section className="border-b border-stone-200 bg-white px-6 py-12">
<div className="mx-auto max-w-7xl">
<div className="mb-7 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">
Portal coverage
</p>
<h2 className="mt-3 text-3xl font-semibold text-slate-950">
From chef demand to dock handoff
</h2>
</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>;
};