This commit is contained in:
Flatlogic Bot 2026-05-08 09:12:53 +00:00
parent 45dc3fadc9
commit e8ed8d174b
50 changed files with 6598 additions and 4551 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,50 +6,36 @@ const { checkPermissions } = require('../middlewares/check-permissions');
const router = express.Router(); const router = express.Router();
const workspacePermissions = [ const workspacePermissions = ['READ_BUYER_PORTAL'];
'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 = [ const createSavedListPermissions = [
'READ_BUYER_PORTAL',
'CREATE_SAVED_LISTS', 'CREATE_SAVED_LISTS',
'CREATE_SAVED_LIST_ITEMS', 'CREATE_SAVED_LIST_ITEMS',
'READ_ACCOUNTS',
'READ_PRODUCTS',
'READ_PRICE_LIST_ITEMS',
'READ_ACCOUNT_PRICE_LISTS',
]; ];
const createOrderPermissions = [ const createOrderPermissions = [
'READ_BUYER_PORTAL',
'CREATE_ORDERS', 'CREATE_ORDERS',
'CREATE_ORDER_ITEMS', 'CREATE_ORDER_ITEMS',
'READ_ACCOUNTS', ];
'READ_LOCATIONS',
'READ_PRODUCTS', const createSampleRequestPermissions = [
'READ_PRICE_LIST_ITEMS', 'READ_BUYER_PORTAL',
'READ_ACCOUNT_PRICE_LISTS', 'CREATE_SAMPLE_REQUESTS',
]; ];
router.get( router.get(
'/workspace', '/workspace',
...workspacePermissions.map((permission) => checkPermissions(permission)), ...workspacePermissions.map((permission) => checkPermissions(permission)),
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const workspace = await BuyerPortalService.workspace({ const workspace = await BuyerPortalService.workspace(
{
accountId: req.query.accountId, accountId: req.query.accountId,
locationId: req.query.locationId, locationId: req.query.locationId,
}); },
req.currentUser,
);
res.status(200).send(workspace); res.status(200).send(workspace);
}), }),
@ -83,4 +69,19 @@ router.post(
}), }),
); );
router.post(
'/sample-requests',
...createSampleRequestPermissions.map((permission) =>
checkPermissions(permission),
),
wrapAsync(async (req, res) => {
const sampleRequest = await BuyerPortalService.createSampleRequest(
req.body,
req.currentUser,
);
res.status(200).send({ sampleRequest });
}),
);
module.exports = router; module.exports = router;

View File

@ -19,9 +19,66 @@ function toNumber(value) {
function makeError(message) { function makeError(message) {
const error = new Error(message); const error = new Error(message);
error.code = 400; error.code = 400;
error.status = 400;
error.statusCode = 400;
return error; return error;
} }
const BUYER_ROLE_NAMES = ['Customer Buyer Admin', 'Customer Buyer'];
function isCustomerBuyer(currentUser) {
return currentUser?.app_role?.name === 'Customer Buyer';
}
function isCustomerBuyerAdmin(currentUser) {
return currentUser?.app_role?.name === 'Customer Buyer Admin';
}
function isBuyerRole(currentUser) {
return BUYER_ROLE_NAMES.includes(currentUser?.app_role?.name);
}
async function getBuyerAccountIds(currentUser) {
if (!isBuyerRole(currentUser)) {
return null;
}
if (!currentUser.email) {
return [];
}
const contacts = await db.contacts.findAll({
where: {
email: {
[Op.iLike]: currentUser.email,
},
is_active: true,
},
});
return Array.from(
new Set(
contacts
.map((contact) => contact.accountId)
.filter((accountId) => Boolean(accountId)),
),
);
}
async function assertAccountAccess(accountId, currentUser) {
const buyerAccountIds = await getBuyerAccountIds(currentUser);
if (buyerAccountIds === null) {
return;
}
if (!buyerAccountIds.includes(accountId)) {
throw makeError(
'Your buyer account is not allowed to access the selected customer account.',
);
}
}
function isCurrent(start, end) { function isCurrent(start, end) {
const now = new Date(); const now = new Date();
@ -48,6 +105,18 @@ function buildOrderNumber() {
return `ORD-${stamp}-${suffix}`; return `ORD-${stamp}-${suffix}`;
} }
function buildSampleRequestNumber() {
const now = new Date();
const stamp = [
now.getUTCFullYear(),
String(now.getUTCMonth() + 1).padStart(2, '0'),
String(now.getUTCDate()).padStart(2, '0'),
].join('');
const suffix = String(Math.floor(1000 + Math.random() * 9000));
return `SR-${stamp}-${suffix}`;
}
function serializeUser(user) { function serializeUser(user) {
if (!user) { if (!user) {
return null; return null;
@ -284,14 +353,23 @@ async function ensureBuyerPortalSeeded() {
await buyerPortalSeedPromise; await buyerPortalSeedPromise;
} }
async function getAccounts() { async function getAccounts(currentUser) {
const accounts = await db.accounts.findAll({ const buyerAccountIds = await getBuyerAccountIds(currentUser);
where: { const where = {
is_active: true, is_active: true,
default_price_listId: { default_price_listId: {
[Op.ne]: null, [Op.ne]: null,
}, },
}, };
if (buyerAccountIds !== null) {
where.id = {
[Op.in]: buyerAccountIds,
};
}
const accounts = await db.accounts.findAll({
where,
include: [ include: [
{ {
model: db.price_lists, model: db.price_lists,
@ -517,11 +595,17 @@ async function getCatalogForAccount(account) {
); );
} }
async function getRecentOrders(accountId) { async function getRecentOrders(accountId, currentUser) {
const orders = await db.orders.findAll({ const where = {
where: {
accountId, accountId,
}, };
if (isCustomerBuyer(currentUser)) {
where.buyerId = currentUser.id;
}
const orders = await db.orders.findAll({
where,
include: [ include: [
{ {
model: db.locations, model: db.locations,
@ -563,11 +647,17 @@ async function getRecentOrders(accountId) {
return orders.map((order) => serializeOrder(order.get({ plain: true }))); return orders.map((order) => serializeOrder(order.get({ plain: true })));
} }
async function getRecentQuotes(accountId) { async function getRecentQuotes(accountId, currentUser) {
const quotes = await db.quotes.findAll({ const where = {
where: {
accountId, accountId,
}, };
if (isCustomerBuyer(currentUser)) {
where.requested_byId = currentUser.id;
}
const quotes = await db.quotes.findAll({
where,
include: [ include: [
{ {
model: db.locations, model: db.locations,
@ -614,11 +704,17 @@ async function getRecentQuotes(accountId) {
return quotes.map((quote) => serializeQuote(quote.get({ plain: true }))); return quotes.map((quote) => serializeQuote(quote.get({ plain: true })));
} }
async function getRecentSamples(accountId) { async function getRecentSamples(accountId, currentUser) {
const sampleRequests = await db.sample_requests.findAll({ const where = {
where: {
accountId, accountId,
}, };
if (isCustomerBuyer(currentUser)) {
where.requested_byId = currentUser.id;
}
const sampleRequests = await db.sample_requests.findAll({
where,
include: [ include: [
{ {
model: db.products, model: db.products,
@ -655,11 +751,17 @@ async function getRecentSamples(accountId) {
); );
} }
async function getSavedLists(accountId) { async function getSavedLists(accountId, currentUser) {
const savedLists = await db.saved_lists.findAll({ const where = {
where: {
accountId, accountId,
}, };
if (isCustomerBuyer(currentUser)) {
where.ownerId = currentUser.id;
}
const savedLists = await db.saved_lists.findAll({
where,
include: [ include: [
{ {
model: db.users, model: db.users,
@ -692,10 +794,10 @@ async function getSavedLists(accountId) {
} }
module.exports = class BuyerPortalService { module.exports = class BuyerPortalService {
static async workspace(filter = {}) { static async workspace(filter = {}, currentUser) {
await ensureBuyerPortalSeeded(); await ensureBuyerPortalSeeded();
const accounts = await getAccounts(); const accounts = await getAccounts(currentUser);
const accountIds = accounts.map((account) => account.id); const accountIds = accounts.map((account) => account.id);
const locations = await getLocations(accountIds); const locations = await getLocations(accountIds);
const contacts = await getContacts(accountIds); const contacts = await getContacts(accountIds);
@ -721,16 +823,16 @@ module.exports = class BuyerPortalService {
const catalog = await getCatalogForAccount(selectedAccountWithPricing); const catalog = await getCatalogForAccount(selectedAccountWithPricing);
const recentOrders = selectedAccount const recentOrders = selectedAccount
? await getRecentOrders(selectedAccount.id) ? await getRecentOrders(selectedAccount.id, currentUser)
: []; : [];
const recentQuotes = selectedAccount const recentQuotes = selectedAccount
? await getRecentQuotes(selectedAccount.id) ? await getRecentQuotes(selectedAccount.id, currentUser)
: []; : [];
const recentSamples = selectedAccount const recentSamples = selectedAccount
? await getRecentSamples(selectedAccount.id) ? await getRecentSamples(selectedAccount.id, currentUser)
: []; : [];
const savedLists = selectedAccount const savedLists = selectedAccount
? await getSavedLists(selectedAccount.id) ? await getSavedLists(selectedAccount.id, currentUser)
: []; : [];
return { return {
@ -742,6 +844,10 @@ module.exports = class BuyerPortalService {
recentQuotes, recentQuotes,
recentSamples, recentSamples,
savedLists, savedLists,
roleContext: {
isBuyerAdmin: isCustomerBuyerAdmin(currentUser),
isBuyerUser: isCustomerBuyer(currentUser),
},
}; };
} }
@ -756,6 +862,8 @@ module.exports = class BuyerPortalService {
throw makeError('Choose an account before saving a reorder list.'); throw makeError('Choose an account before saving a reorder list.');
} }
await assertAccountAccess(payload.accountId, currentUser);
if (!payload.listName || !String(payload.listName).trim()) { if (!payload.listName || !String(payload.listName).trim()) {
throw makeError('Give this reorder list a name.'); throw makeError('Give this reorder list a name.');
} }
@ -893,6 +1001,8 @@ module.exports = class BuyerPortalService {
throw makeError('Choose an account before placing an order.'); throw makeError('Choose an account before placing an order.');
} }
await assertAccountAccess(payload.accountId, currentUser);
if (!payload.locationId) { if (!payload.locationId) {
throw makeError('Choose a delivery location before placing an order.'); throw makeError('Choose a delivery location before placing an order.');
} }
@ -1071,4 +1181,123 @@ module.exports = class BuyerPortalService {
throw error; throw error;
} }
} }
static async createSampleRequest(payload = {}, currentUser) {
await ensureBuyerPortalSeeded();
if (!currentUser?.id) {
throw makeError('You must be signed in to request a sample.');
}
if (!payload.accountId) {
throw makeError('Choose an account before requesting a sample.');
}
await assertAccountAccess(payload.accountId, currentUser);
if (!payload.locationId) {
throw makeError('Choose a delivery location before requesting a sample.');
}
if (!payload.productId) {
throw makeError('Choose a sample-eligible product.');
}
const quantity = Number.parseInt(String(payload.sampleQuantity), 10);
if (!Number.isInteger(quantity) || quantity <= 0) {
throw makeError('Enter a valid sample quantity.');
}
const account = await db.accounts.findOne({
where: {
id: payload.accountId,
is_active: true,
},
include: [
{
model: db.price_lists,
as: 'default_price_list',
required: false,
},
],
});
if (!account) {
throw makeError('The selected account could not be found.');
}
const location = await db.locations.findOne({
where: {
id: payload.locationId,
accountId: payload.accountId,
is_active: true,
},
});
if (!location) {
throw makeError(
'The selected delivery location is invalid for this account.',
);
}
const catalog = await getCatalogForAccount(account.get({ plain: true }));
const product = catalog.find((item) => item.id === payload.productId);
if (!product) {
throw makeError(
'The selected product is no longer on the contract catalog.',
);
}
if (!product.is_sample_eligible) {
throw makeError(`${product.product_name} is not sample eligible.`);
}
const sampleRequest = await db.sample_requests.create({
sample_request_number: buildSampleRequestNumber(),
sample_quantity: quantity,
requested_at: new Date(),
needed_by: payload.neededBy || null,
sample_status: 'requested',
notes: payload.notes || null,
accountId: account.id,
locationId: location.id,
requested_byId: currentUser.id,
productId: product.id,
createdById: currentUser.id,
updatedById: currentUser.id,
});
const createdSampleRequest = await db.sample_requests.findByPk(
sampleRequest.id,
{
include: [
{
model: db.products,
as: 'product',
required: false,
},
{
model: db.locations,
as: 'location',
required: false,
include: [
{
model: db.contacts,
as: 'default_contact',
required: false,
},
],
},
{
model: db.users,
as: 'requested_by',
required: false,
},
],
},
);
return serializeSampleRequest(createdSampleRequest.get({ plain: true }));
}
}; };

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

BIN
frontend/src/._colors.ts Normal file

Binary file not shown.

BIN
frontend/src/._menuAside.ts Normal file

Binary file not shown.

BIN
frontend/src/._styles.ts Normal file

Binary file not shown.

View File

@ -1,21 +1,22 @@
import type { ColorButtonKey } from './interfaces' import type { ColorButtonKey } from './interfaces';
export const gradientBgBase = 'bg-gradient-to-tr' export const gradientBgBase = 'bg-gradient-to-tr';
export const colorBgBase = "bg-violet-50/50" export const colorBgBase = 'bg-violet-50/50';
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500` export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`;
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}` export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`;
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`; export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500` export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`;
export const colorsBgLight = { export const colorsBgLight = {
white: 'bg-white text-black', white: 'bg-white text-black',
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white', light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black', contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', success:
'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
danger: 'bg-red-500 border-red-500 text-white', danger: 'bg-red-500 border-red-500 text-white',
warning: 'bg-yellow-500 border-yellow-500 text-white', warning: 'bg-yellow-500 border-yellow-500 text-white',
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white', info: 'bg-slate-900 border-slate-900 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
} };
export const colorsText = { export const colorsText = {
white: 'text-black dark:text-slate-100', white: 'text-black dark:text-slate-100',
@ -24,13 +25,15 @@ export const colorsText = {
success: 'text-emerald-500', success: 'text-emerald-500',
danger: 'text-red-500', danger: 'text-red-500',
warning: 'text-yellow-500', warning: 'text-yellow-500',
info: 'text-blue-500', info: 'text-slate-900',
}; };
export const colorsOutline = { export const colorsOutline = {
white: [colorsText.white, 'border-gray-100'].join(' '), white: [colorsText.white, 'border-gray-100'].join(' '),
light: [colorsText.light, 'border-gray-100'].join(' '), light: [colorsText.light, 'border-gray-100'].join(' '),
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(' '), contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(
' ',
),
success: [colorsText.success, 'border-emerald-500'].join(' '), success: [colorsText.success, 'border-emerald-500'].join(' '),
danger: [colorsText.danger, 'border-red-500'].join(' '), danger: [colorsText.danger, 'border-red-500'].join(' '),
warning: [colorsText.warning, 'border-yellow-500'].join(' '), warning: [colorsText.warning, 'border-yellow-500'].join(' '),
@ -41,10 +44,10 @@ export const getButtonColor = (
color: ColorButtonKey, color: ColorButtonKey,
isOutlined: boolean, isOutlined: boolean,
hasHover: boolean, hasHover: boolean,
isActive = false isActive = false,
) => { ) => {
if (color === 'void') { if (color === 'void') {
return '' return '';
} }
const colors = { const colors = {
@ -56,7 +59,7 @@ export const getButtonColor = (
success: 'ring-emerald-300 dark:ring-pavitra-blue', success: 'ring-emerald-300 dark:ring-pavitra-blue',
danger: 'ring-red-300 dark:ring-red-700', danger: 'ring-red-300 dark:ring-red-700',
warning: 'ring-yellow-300 dark:ring-yellow-700', warning: 'ring-yellow-300 dark:ring-yellow-700',
info: "ring-blue-300 dark:ring-pavitra-blue", info: 'ring-slate-300 dark:ring-pavitra-blue',
}, },
active: { active: {
white: 'bg-gray-100', white: 'bg-gray-100',
@ -66,7 +69,7 @@ export const getButtonColor = (
success: 'bg-emerald-700 dark:bg-pavitra-blue', success: 'bg-emerald-700 dark:bg-pavitra-blue',
danger: 'bg-red-700 dark:bg-red-600', danger: 'bg-red-700 dark:bg-red-600',
warning: 'bg-yellow-700 dark:bg-yellow-600', warning: 'bg-yellow-700 dark:bg-yellow-600',
info: 'bg-blue-700 dark:bg-pavitra-blue', info: 'bg-slate-800 dark:bg-pavitra-blue',
}, },
bg: { bg: {
white: 'bg-white text-black', white: 'bg-white text-black',
@ -76,7 +79,7 @@ export const getButtonColor = (
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white', success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
danger: 'bg-red-600 text-white dark:bg-red-500 ', danger: 'bg-red-600 text-white dark:bg-red-500 ',
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white', warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
info: " bg-blue-600 dark:bg-pavitra-blue text-white ", info: ' bg-slate-900 dark:bg-pavitra-blue text-white ',
}, },
bgHover: { bgHover: {
white: 'hover:bg-gray-100', white: 'hover:bg-gray-100',
@ -89,7 +92,7 @@ export const getButtonColor = (
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600', 'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
warning: warning:
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600', 'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80", info: 'hover:bg-slate-800 hover:border-slate-800 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80',
}, },
borders: { borders: {
white: 'border-white', white: 'border-white',
@ -99,40 +102,44 @@ export const getButtonColor = (
success: 'border-emerald-600 dark:border-pavitra-blue', success: 'border-emerald-600 dark:border-pavitra-blue',
danger: 'border-red-600 dark:border-red-500', danger: 'border-red-600 dark:border-red-500',
warning: 'border-yellow-600 dark:border-yellow-500', warning: 'border-yellow-600 dark:border-yellow-500',
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue", info: 'border-slate-900 dark:border-pavitra-blue',
}, },
text: { text: {
contrast: 'dark:text-slate-100', contrast: 'dark:text-slate-100',
success: 'text-emerald-600 dark:text-pavitra-blue', success: 'text-emerald-600 dark:text-pavitra-blue',
danger: 'text-red-600 dark:text-red-500', danger: 'text-red-600 dark:text-red-500',
warning: 'text-yellow-600 dark:text-yellow-500', warning: 'text-yellow-600 dark:text-yellow-500',
info: 'text-blue-600 dark:text-pavitra-blue', info: 'text-slate-900 dark:text-pavitra-blue',
}, },
outlineHover: { outlineHover: {
contrast: contrast:
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black', 'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue', success:
'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
danger: danger:
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600', 'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
warning: warning:
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600', 'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue", info: 'hover:bg-slate-900 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
}, },
} };
const isOutlinedProcessed = isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0 const isOutlinedProcessed =
isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0;
const base = [colors.borders[color], colors.ring[color]] const base = [colors.borders[color], colors.ring[color]];
if (isActive) { if (isActive) {
base.push(colors.active[color]) base.push(colors.active[color]);
} else { } else {
base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color]) base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color]);
} }
if (hasHover) { if (hasHover) {
base.push(isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color]) base.push(
isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color],
);
} }
return base.join(' ') return base.join(' ');
} };

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,14 +1,14 @@
import React from 'react' import React from 'react';
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces';
import AsideMenuLayer from './AsideMenuLayer' import AsideMenuLayer from './AsideMenuLayer';
import OverlayLayer from './OverlayLayer' import OverlayLayer from './OverlayLayer';
type Props = { type Props = {
menu: MenuAsideItem[] menu: MenuAsideItem[];
isAsideMobileExpanded: boolean isAsideMobileExpanded: boolean;
isAsideLgActive: boolean isAsideLgActive: boolean;
onAsideLgClose: () => void onAsideLgClose: () => void;
} };
export default function AsideMenu({ export default function AsideMenu({
isAsideMobileExpanded = false, isAsideMobileExpanded = false,
@ -19,12 +19,14 @@ export default function AsideMenu({
<> <>
<AsideMenuLayer <AsideMenuLayer
menu={props.menu} menu={props.menu}
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${ className={`${
!isAsideLgActive ? 'lg:hidden xl:flex' : '' isAsideMobileExpanded ? 'left-0' : '-left-72 lg:left-0'
}`} } ${!isAsideLgActive ? 'lg:hidden xl:flex' : ''}`}
onAsideLgCloseClick={props.onAsideLgClose} onAsideLgCloseClick={props.onAsideLgClose}
/> />
{isAsideLgActive && <OverlayLayer zIndex="z-30" onClick={props.onAsideLgClose} />} {isAsideLgActive && (
<OverlayLayer zIndex='z-30' onClick={props.onAsideLgClose} />
)}
</> </>
) );
} }

View File

@ -1,102 +1,108 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react';
import { mdiMinus, mdiPlus } from '@mdi/js' import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
import BaseIcon from './BaseIcon' import Link from 'next/link';
import Link from 'next/link' import { useRouter } from 'next/router';
import { getButtonColor } from '../colors' 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 { useRouter } from 'next/router'
type Props = { type Props = {
item: MenuAsideItem item: MenuAsideItem;
isDropdownList?: boolean isDropdownList?: boolean;
} };
const getViewName = (href: string) => {
return new URL(href, location.href).pathname.split('/')[1];
};
const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
const [isLinkActive, setIsLinkActive] = useState(false) const [isLinkActive, setIsLinkActive] = useState(false);
const [isDropdownActive, setIsDropdownActive] = useState(false) const [isDropdownActive, setIsDropdownActive] = useState(false);
const { asPath, isReady } = useRouter();
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
const borders = useAppSelector((state) => state.style.borders);
const activeLinkColor = useAppSelector(
(state) => state.style.activeLinkColor,
);
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
const { asPath, isReady } = useRouter()
useEffect(() => { useEffect(() => {
if (item.href && isReady) { if (!isReady) {
const linkPathName = new URL(item.href, location.href).pathname + '/'; return;
const activePathname = new URL(asPath, location.href).pathname
const activeView = activePathname.split('/')[1];
const linkPathNameView = linkPathName.split('/')[1];
setIsLinkActive(linkPathNameView === activeView);
} }
}, [item.href, isReady, asPath])
const activeView = new URL(asPath, location.href).pathname.split('/')[1];
const currentItemView = item.href ? getViewName(item.href) : '';
const activeChild = (item.menu || []).some((child) => {
if (!child.href) {
return false;
}
return getViewName(child.href) === activeView;
});
setIsLinkActive(currentItemView === activeView || activeChild);
if (activeChild) {
setIsDropdownActive(true);
}
}, [item.href, item.menu, isReady, asPath]);
const activeClass = isLinkActive
? 'bg-emerald-50 text-slate-950 ring-1 ring-emerald-200'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-dark-800 dark:hover:text-white';
const componentClass = [
'group flex min-h-10 cursor-pointer items-center rounded-xl px-3 py-2 text-sm font-semibold transition-colors',
isDropdownList ? 'ml-3 text-[13px]' : '',
item.color ? '' : activeClass,
].join(' ');
const iconClass = isLinkActive
? 'text-emerald-700'
: 'text-slate-400 group-hover:text-slate-700';
const asideMenuItemInnerContents = ( const asideMenuItemInnerContents = (
<> <>
{item.icon && ( {item.icon && (
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" /> <BaseIcon
path={item.icon}
className={`mr-3 flex-none ${iconClass}`}
size='18'
/>
)} )}
<span <span className='grow text-ellipsis line-clamp-1'>{item.label}</span>
className={`grow text-ellipsis line-clamp-1 ${
item.menu ? '' : 'pr-12'
} ${activeClassAddon}`}
>
{item.label}
</span>
{item.menu && ( {item.menu && (
<BaseIcon <BaseIcon
path={isDropdownActive ? mdiMinus : mdiPlus} path={isDropdownActive ? mdiChevronDown : mdiChevronRight}
className={`flex-none ${activeClassAddon}`} className={`flex-none ${iconClass}`}
w="w-12" w='w-5'
/> />
)} )}
</> </>
) );
const componentClass = [
'flex cursor-pointer py-1.5 ',
isDropdownList ? 'px-6 text-sm' : '',
item.color
? getButtonColor(item.color, false, true)
: `${asideMenuItemStyle}`,
isLinkActive
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800`
: '',
].join(' ');
return ( return (
<li className={'px-3 py-1.5'}> <li className='py-0.5'>
{item.withDevider && <hr className={`${borders} mb-3`} />} {item.withDevider && <hr className='mb-3 border-slate-200' />}
{item.href && ( {item.href && (
<Link href={item.href} target={item.target} className={componentClass}> <Link href={item.href} target={item.target} className={componentClass}>
{asideMenuItemInnerContents} {asideMenuItemInnerContents}
</Link> </Link>
)} )}
{!item.href && ( {!item.href && (
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)}> <div
className={componentClass}
onClick={() => setIsDropdownActive(!isDropdownActive)}
>
{asideMenuItemInnerContents} {asideMenuItemInnerContents}
</div> </div>
)} )}
{item.menu && ( {item.menu && (
<AsideMenuList <AsideMenuList
menu={item.menu} menu={item.menu}
className={`${asideMenuDropdownStyle} ${ className={`${
isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden' isDropdownActive ? 'block' : 'hidden'
}`} } mt-1 space-y-1 border-l border-slate-200 pl-2`}
isDropdownList isDropdownList
/> />
)} )}
</li> </li>
) );
} };
export default AsideMenuItem export default AsideMenuItem;

View File

@ -1,63 +1,79 @@
import React from 'react' import React from 'react';
import { mdiLogout, mdiClose } from '@mdi/js' import { 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 { useAppSelector } from '../stores/hooks';
import Link from 'next/link';
type Props = { type Props = {
menu: MenuAsideItem[] menu: MenuAsideItem[];
className?: string className?: string;
onAsideLgCloseClick: () => void onAsideLgCloseClick: () => void;
} };
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) { export default function AsideMenuLayer({
const corners = useAppSelector((state) => state.style.corners); menu,
const asideStyle = useAppSelector((state) => state.style.asideStyle) className = '',
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle) ...props
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) }: Props) {
const darkMode = useAppSelector((state) => state.style.darkMode) const asideStyle = useAppSelector((state) => state.style.asideStyle);
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const darkMode = useAppSelector((state) => state.style.darkMode);
const handleAsideLgCloseClick = (e: React.MouseEvent) => { const handleAsideLgCloseClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault();
props.onAsideLgCloseClick() props.onAsideLgCloseClick();
} };
return ( return (
<aside <aside
id='asideMenu' id='asideMenu'
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`} className={`${className} fixed top-0 z-40 flex h-screen w-72 overflow-hidden transition-position`}
> >
<div <div
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`} className={`flex flex-1 flex-col overflow-hidden ${asideStyle} dark:bg-dark-900`}
> >
<div <div className='border-b border-slate-200 px-5 py-5'>
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`} <div className='flex items-start justify-between gap-3'>
> <div>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <p className='text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700'>
Northstar
<b className="font-black">B2B Distributor Portal</b> </p>
<h2 className='mt-2 text-lg font-bold text-slate-950 dark:text-white'>
Foodservice Ops
</h2>
<p className='mt-1 text-xs font-medium text-slate-500'>
Supplier / Distributor B2B Portal
</p>
</div> </div>
<button <button
className="hidden lg:inline-block xl:hidden p-3" className='hidden rounded-lg border border-slate-200 p-2 text-slate-500 hover:bg-slate-100 lg:inline-block xl:hidden'
onClick={handleAsideLgCloseClick} onClick={handleAsideLgCloseClick}
> >
<BaseIcon path={mdiClose} /> <BaseIcon path={mdiClose} />
</button> </button>
</div> </div>
</div>
<div <div
className={`flex-1 overflow-y-auto overflow-x-hidden ${ className={`flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`} }`}
> >
<AsideMenuList menu={menu} /> <AsideMenuList menu={menu} />
</div> </div>
<div className='border-t border-slate-200 px-5 py-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>
Workspace
</p>
<p className='mt-1 text-sm font-semibold text-slate-700 dark:text-slate-200'>
Contract catalog + orders
</p>
</div>
</div> </div>
</aside> </aside>
) );
} }

View File

@ -1,35 +1,37 @@
import React from 'react' import React from 'react';
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces';
import AsideMenuItem from './AsideMenuItem' import AsideMenuItem from './AsideMenuItem';
import {useAppSelector} from "../stores/hooks"; import { useAppSelector } from '../stores/hooks';
import {hasPermission} from "../helpers/userPermissions"; import { hasPermission } from '../helpers/userPermissions';
type Props = { type Props = {
menu: MenuAsideItem[] menu: MenuAsideItem[];
isDropdownList?: boolean isDropdownList?: boolean;
className?: string className?: string;
} };
export default function AsideMenuList({ menu, isDropdownList = false, className = '' }: Props) { export default function AsideMenuList({
menu,
isDropdownList = false,
className = '',
}: Props) {
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
if (!currentUser) return null; if (!currentUser) return null;
return ( return (
<ul className={className}> <ul className={className || 'space-y-1'}>
{menu.map((item, index) => { {menu.map((item, index) => {
if (!hasPermission(currentUser, item.permissions)) return null; if (!hasPermission(currentUser, item.permissions)) return null;
return ( return (
<div key={index}>
<AsideMenuItem <AsideMenuItem
key={item.href || item.label || index}
item={item} item={item}
isDropdownList={isDropdownList} isDropdownList={isDropdownList}
/> />
</div> );
)
})} })}
</ul> </ul>
) );
} }

View File

@ -1,23 +1,23 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react';
import CardBoxComponentBody from './CardBoxComponentBody' import CardBoxComponentBody from './CardBoxComponentBody';
import CardBoxComponentFooter from './CardBoxComponentFooter' import CardBoxComponentFooter from './CardBoxComponentFooter';
import { useAppSelector } from '../stores/hooks'; import { useAppSelector } from '../stores/hooks';
type Props = { type Props = {
rounded?: string rounded?: string;
flex?: string flex?: string;
className?: string className?: string;
hasComponentLayout?: boolean hasComponentLayout?: boolean;
cardBoxClassName?: string cardBoxClassName?: string;
hasTable?: boolean hasTable?: boolean;
isHoverable?: boolean isHoverable?: boolean;
isModal?: boolean isModal?: boolean;
children?: ReactNode children?: ReactNode;
footer?: ReactNode footer?: ReactNode;
isList?:boolean isList?: boolean;
id?: string; id?: string;
onClick?: (e: React.MouseEvent) => void onClick?: (e: React.MouseEvent) => void;
} };
export default function CardBox({ export default function CardBox({
rounded = 'rounded', rounded = 'rounded',
@ -42,11 +42,11 @@ export default function CardBox({
corners !== 'rounded-full' ? corners : 'rounded-3xl', corners !== 'rounded-full' ? corners : 'rounded-3xl',
flex, flex,
isList ? '' : `${cardsStyle}`, isList ? '' : `${cardsStyle}`,
hasTable ? '' : `border-dark-700 dark:border-dark-700`, hasTable ? '' : `dark:border-dark-700`,
] ];
if (isHoverable) { if (isHoverable) {
componentClass.push('hover:shadow-lg transition-shadow duration-500') componentClass.push('transition-shadow duration-200 hover:shadow-md');
} }
return React.createElement( return React.createElement(
@ -56,9 +56,15 @@ export default function CardBox({
children children
) : ( ) : (
<> <>
<CardBoxComponentBody id={id} noPadding={hasTable} className={cardBoxClassName}>{children}</CardBoxComponentBody> <CardBoxComponentBody
id={id}
noPadding={hasTable}
className={cardBoxClassName}
>
{children}
</CardBoxComponentBody>
{footer && <CardBoxComponentFooter>{footer}</CardBoxComponentFooter>} {footer && <CardBoxComponentFooter>{footer}</CardBoxComponentFooter>}
</> </>
) ),
) );
} }

View File

@ -1,25 +1,23 @@
import React, { Component, ErrorInfo, ReactNode } from 'react'; import React, { Component, ErrorInfo, ReactNode } from "react";
import { mdiAlertCircle } from '@mdi/js'; import { mdiAlertCircle } from "@mdi/js";
import BaseIcon from './BaseIcon';
// Define the props and state interfaces import BaseIcon from "./BaseIcon";
interface ErrorBoundaryProps {
type ErrorBoundaryProps = {
children: ReactNode; children: ReactNode;
} };
interface ErrorBoundaryState { type ErrorBoundaryState = {
hasError: boolean; hasError: boolean;
error: Error | null; error: Error | null;
errorInfo: ErrorInfo | null; errorInfo: ErrorInfo | null;
showStack: boolean; showStack: boolean;
} };
// Class-based ErrorBoundary Component
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) { constructor(props: ErrorBoundaryProps) {
super(props); super(props);
// Define state variables
this.state = { this.state = {
hasError: false, hasError: false,
error: null, error: null,
@ -29,80 +27,18 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
} }
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> { static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
// Update state so the next render will show the fallback UI
return { return {
hasError: true, hasError: true,
error: error, error,
}; };
} }
componentDidUpdate( componentDidCatch(error: Error, errorInfo: ErrorInfo) {
prevProps: Readonly<ErrorBoundaryProps>, console.error("Error caught in boundary:", error, errorInfo);
prevState: Readonly<ErrorBoundaryState>,
snapshot?: any,
) {
if (process.env.NODE_ENV !== 'production') {
console.log('componentDidUpdate');
}
}
async componentWillUnmount() {
if (process.env.NODE_ENV !== 'production') {
console.log('componentWillUnmount');
const response = await fetch('/api/logError', {
method: 'DELETE',
});
const data = await response.json();
console.log('Error logs cleared:', data);
}
}
async componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Update state with error details (always needed for UI)
this.setState({ this.setState({
errorInfo: errorInfo, errorInfo,
}); });
// Only perform logging in non-production environments
if (process.env.NODE_ENV !== 'production') {
console.log('Error caught in boundary:', error, errorInfo);
// Function to log errors to the server
const logErrorToServer = async () => {
try {
const response = await fetch('/api/logError', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: error.message,
stack: errorInfo.componentStack,
}),
});
const data = await response.json();
console.log('Error logged:', data);
} catch (err) {
console.error('Failed to log error:', err);
}
};
// Function to fetch logged errors (optional)
const fetchLoggedErrors = async () => {
try {
const response = await fetch('/api/logError');
const data = await response.json();
console.log('Fetched logs:', data);
} catch (err) {
console.error('Failed to fetch logs:', err);
}
};
await logErrorToServer();
await fetchLoggedErrors();
}
} }
toggleStack = () => { toggleStack = () => {
@ -120,99 +56,63 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}); });
}; };
tryAgain = async () => {
// Only clear error logs in non-production environments
if (process.env.NODE_ENV !== 'production') {
try {
const response = await fetch('/api/logError', {
method: 'DELETE',
});
const data = await response.json();
console.log('Error logs cleared:', data);
} catch (e) {
console.error('Failed to clear error logs:', e);
}
}
// Always reset the error state (needed for UI recovery)
this.setState({ hasError: false });
};
render() { render() {
if (this.state.hasError) { if (!this.state.hasError) {
// Extract error details return this.props.children;
}
const { error, errorInfo, showStack } = this.state; const { error, errorInfo, showStack } = this.state;
const errorMessage = error?.message || 'An unexpected error occurred'; const errorMessage = error?.message || "An unexpected error occurred";
const stackTrace = const stackTrace =
errorInfo?.componentStack || error?.stack || 'No stack trace available'; errorInfo?.componentStack || error?.stack || "No stack trace available";
return ( return (
<div className='flex items-center justify-center min-h-screen bg-pavitra-300'> <div className="flex min-h-screen items-center justify-center bg-slate-100 p-6">
<div className='max-w-lg w-full p-8 bg-white rounded-lg shadow-sm'> <div className="w-full max-w-xl rounded-2xl border border-rose-200 bg-white p-8 shadow-sm">
<div className='flex flex-col items-center text-center space-y-6'> <div className="flex items-center gap-3">
<div className='p-3 bg-pavitra-500 rounded-full flex items-center justify-center'> <div className="rounded-2xl bg-rose-50 p-3 text-rose-600">
<BaseIcon <BaseIcon path={mdiAlertCircle} size={28} />
path={mdiAlertCircle}
size={32}
className='text-pavitra-red'
/>
</div> </div>
<div>
<div className='space-y-2'> <h1 className="text-xl font-semibold text-slate-950">
<h2 className='text-xl font-semibold text-pavitra-900'>
Something went wrong Something went wrong
</h2> </h1>
<p className='text-pavitra-800'> <p className="mt-1 text-sm text-slate-600">
We&apos;re sorry, but we encountered an unexpected error. The app stopped on a visible frontend error.
</p> </p>
</div> </div>
</div>
<div className='w-full text-left p-4 bg-pavitra-400 rounded-md overflow-hidden'> <div className="mt-6 rounded-xl bg-rose-50 p-4 font-mono text-sm text-rose-800">
<p className='font-mono text-sm text-pavitra-red break-words'>
{errorMessage} {errorMessage}
</p> </div>
<div className='mt-4'>
<button
onClick={this.toggleStack}
className='text-xs text-pavitra-800 flex items-center gap-1'
>
<span>{showStack ? 'Hide' : 'Show'} stack trace</span>
<span className='text-xs'>{showStack ? '▲' : '▼'}</span>
</button>
{showStack && ( {showStack && (
<pre className='mt-2 p-3 bg-pavitra-500 rounded text-xs font-mono text-pavitra-900 overflow-x-auto max-h-64'> <pre className="mt-4 max-h-72 overflow-auto rounded-xl bg-slate-950 p-4 text-xs text-slate-100">
{stackTrace} {stackTrace}
</pre> </pre>
)} )}
</div>
</div>
<div className='space-y-4 w-full'> <div className="mt-6 flex flex-wrap gap-3">
<button <button
className='w-full py-2 px-4 bg-pavitra-blue hover:bg-pavitra-900 text-white rounded-md transition-colors' type="button"
onClick={this.tryAgain}
>
Try Again
</button>
<button
className='w-full py-2 px-4 border border-pavitra-600 text-pavitra-800 hover:bg-pavitra-400 rounded-md transition-colors'
onClick={this.resetError} onClick={this.resetError}
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
> >
Go Back Try again
</button>
<button
type="button"
onClick={this.toggleStack}
className="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
{showStack ? "Hide details" : "Show details"}
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
return this.props.children;
}
} }
export default ErrorBoundary; export default ErrorBoundary;

View File

@ -1,35 +1,31 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react';
import { containerMaxW } from '../config' import { containerMaxW } from '../config';
import Logo from './Logo'
type Props = { type Props = {
children?: ReactNode children?: ReactNode;
} };
export default function FooterBar({ children }: Props) { export default function FooterBar({ children }: Props) {
const year = new Date().getFullYear() const year = new Date().getFullYear();
return ( return (
<footer className={`py-2 px-6 ${containerMaxW}`}> <footer className={`px-5 py-5 lg:px-8 ${containerMaxW}`}>
<div className="block md:flex items-center justify-between"> <div className='flex flex-col gap-2 border-t border-slate-200 pt-5 text-xs text-slate-500 md:flex-row md:items-center md:justify-between'>
<div className="text-center md:text-left mb-6 md:mb-0"> <div>
<b> <span className='font-semibold text-slate-700'>
&copy;{year},{` `} © {year} B2B Distributor Portal.
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank"> </span>{' '}
Flatlogic
</a>
.
</b>
{` `}
{children} {children}
</div> </div>
<a
<div className="flex item-center md:py-2 gap-4"> href='https://flatlogic.com/'
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank"> rel='noreferrer'
<Logo className="w-auto h-8 md:h-6 mx-auto" /> target='_blank'
className='font-semibold text-emerald-700'
>
Powered by Flatlogic
</a> </a>
</div> </div>
</div>
</footer> </footer>
) );
} }

View File

@ -1,28 +1,26 @@
import React, { ReactNode, useState, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react';
import { mdiClose, mdiDotsVertical } from '@mdi/js' import { mdiClose, mdiDotsVertical } from '@mdi/js';
import { containerMaxW } from '../config' import { containerMaxW } from '../config';
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon';
import NavBarItemPlain from './NavBarItemPlain' import NavBarItemPlain from './NavBarItemPlain';
import NavBarMenuList from './NavBarMenuList' import NavBarMenuList from './NavBarMenuList';
import { MenuNavBarItem } from '../interfaces' import { MenuNavBarItem } from '../interfaces';
import { useAppSelector } from '../stores/hooks';
type Props = { type Props = {
menu: MenuNavBarItem[] menu: MenuNavBarItem[];
className: string className: string;
children: ReactNode children: ReactNode;
} };
export default function NavBar({ menu, className = '', children }: Props) { export default function NavBar({ menu, className = '', children }: Props) {
const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false) const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
const scrolled = window.scrollY > 0; setIsScrolled(window.scrollY > 0);
setIsScrolled(scrolled);
}; };
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
return () => { return () => {
window.removeEventListener('scroll', handleScroll); window.removeEventListener('scroll', handleScroll);
@ -30,28 +28,33 @@ export default function NavBar({ menu, className = '', children }: Props) {
}, []); }, []);
const handleMenuNavBarToggleClick = () => { const handleMenuNavBarToggleClick = () => {
setIsMenuNavBarActive(!isMenuNavBarActive) setIsMenuNavBarActive(!isMenuNavBarActive);
} };
return ( return (
<nav <nav
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`} className={`${className} fixed inset-x-0 top-0 z-30 h-16 w-screen bg-white/95 shadow-sm backdrop-blur transition-position lg:w-auto dark:bg-dark-800/95 ${
isScrolled ? 'border-b border-slate-200' : 'border-b border-transparent'
}`}
> >
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}> <div className={`flex h-16 lg:items-stretch ${containerMaxW}`}>
<div className="flex flex-1 items-stretch h-14">{children}</div> <div className='flex h-16 flex-1 items-center'>{children}</div>
<div className="flex-none items-stretch flex h-14 lg:hidden"> <div className='flex h-16 flex-none items-stretch lg:hidden'>
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}> <NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" /> <BaseIcon
path={isMenuNavBarActive ? mdiClose : mdiDotsVertical}
size='24'
/>
</NavBarItemPlain> </NavBarItemPlain>
</div> </div>
<div <div
className={`${ className={`${
isMenuNavBarActive ? 'block' : 'hidden' isMenuNavBarActive ? 'block' : 'hidden'
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`} } absolute left-0 top-16 max-h-screen-menu w-screen overflow-y-auto border-b border-slate-200 bg-white shadow-lg lg:static lg:flex lg:w-auto lg:items-center lg:overflow-visible lg:border-b-0 lg:shadow-none dark:bg-dark-800`}
> >
<NavBarMenuList menu={menu} /> <NavBarMenuList menu={menu} />
</div> </div>
</div> </div>
</nav> </nav>
) );
} }

View File

@ -1,67 +1,59 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react';
import Link from 'next/link' import Link from 'next/link';
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronDown, mdiChevronUp } from '@mdi/js';
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
import UserAvatarCurrentUser from './UserAvatarCurrentUser'
import NavBarMenuList from './NavBarMenuList'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import { MenuNavBarItem } from '../interfaces'
import { setDarkMode } from '../stores/styleSlice'
import { logoutUser } from '../stores/authSlice'
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import ClickOutside from "./ClickOutside"; import BaseDivider from './BaseDivider';
import BaseIcon from './BaseIcon';
import UserAvatarCurrentUser from './UserAvatarCurrentUser';
import NavBarMenuList from './NavBarMenuList';
import ClickOutside from './ClickOutside';
import { MenuNavBarItem } from '../interfaces';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { setDarkMode } from '../stores/styleSlice';
import { logoutUser } from '../stores/authSlice';
type Props = { type Props = {
item: MenuNavBarItem item: MenuNavBarItem;
} };
export default function NavBarItem({ item }: Props) { export default function NavBarItem({ item }: Props) {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const excludedRef = useRef(null); const excludedRef = useRef(null);
const navBarItemLabelActiveColorStyle = useAppSelector(
(state) => state.style.navBarItemLabelActiveColorStyle
)
const navBarItemLabelStyle = useAppSelector((state) => state.style.navBarItemLabelStyle)
const navBarItemLabelHoverStyle = useAppSelector((state) => state.style.navBarItemLabelHoverStyle)
const currentUser = useAppSelector((state) => state.auth.currentUser); const currentUser = useAppSelector((state) => state.auth.currentUser);
const userName = `${currentUser?.firstName ? currentUser?.firstName : ''} ${
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`; currentUser?.lastName ? currentUser?.lastName : ''
}`.trim();
const [isDropdownActive, setIsDropdownActive] = useState(false) const [isDropdownActive, setIsDropdownActive] = useState(false);
useEffect(() => { useEffect(() => {
return () => setIsDropdownActive(false); return () => setIsDropdownActive(false);
}, [router.pathname]); }, [router.pathname]);
const componentClass = [ const componentClass = [
'block lg:flex items-center relative cursor-pointer', 'block cursor-pointer rounded-xl text-sm font-semibold text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 lg:flex lg:items-center lg:mx-1',
isDropdownActive item.menu ? 'lg:px-3 lg:py-2' : 'px-3 py-2',
? `${navBarItemLabelActiveColorStyle} dark:text-slate-400` item.isDesktopNoLabel ? 'lg:w-10 lg:justify-center' : '',
: `${navBarItemLabelStyle} dark:text-white dark:hover:text-slate-400 ${navBarItemLabelHoverStyle}`, ].join(' ');
item.menu ? 'lg:py-2 lg:px-3' : 'py-2 px-3',
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
].join(' ')
const itemLabel = item.isCurrentUser ? userName : item.label const itemLabel = item.isCurrentUser
? userName || currentUser?.email || 'Account'
: item.label;
const handleMenuClick = () => { const handleMenuClick = () => {
if (item.menu) { if (item.menu) {
setIsDropdownActive(!isDropdownActive) setIsDropdownActive(!isDropdownActive);
} }
if (item.isToggleLightDark) { if (item.isToggleLightDark) {
dispatch(setDarkMode(null)) dispatch(setDarkMode(null));
} }
if (item.isLogout) { if (item.isLogout) {
dispatch(logoutUser()) dispatch(logoutUser());
router.push('/login') router.push('/login');
}
} }
};
const getItemId = (label) => { const getItemId = (label) => {
switch (label) { switch (label) {
@ -79,25 +71,27 @@ export default function NavBarItem({ item }: Props) {
<div <div
id={getItemId(itemLabel)} id={getItemId(itemLabel)}
className={`flex items-center ${ className={`flex items-center ${
item.menu item.menu ? 'bg-slate-50 p-3 lg:bg-transparent lg:p-0' : 'w-full'
? 'bg-gray-100 dark:bg-dark-800 lg:bg-transparent lg:dark:bg-transparent p-3 lg:p-0'
: 'w-full'
}`} }`}
onClick={handleMenuClick} onClick={handleMenuClick}
> >
{item.icon && <BaseIcon path={item.icon} size={22} className="transition-colors" />} {item.icon && (
<BaseIcon path={item.icon} size={20} className='transition-colors' />
)}
<span <span
className={`px-2 transition-colors w-40 grow ${ className={`px-2 transition-colors ${
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : '' item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
}`} }`}
> >
{itemLabel} {itemLabel}
</span> </span>
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />} {item.isCurrentUser && (
<UserAvatarCurrentUser className='mr-2 inline-flex h-7 w-7' />
)}
{item.menu && ( {item.menu && (
<BaseIcon <BaseIcon
path={isDropdownActive ? mdiChevronUp : mdiChevronDown} path={isDropdownActive ? mdiChevronUp : mdiChevronDown}
className="hidden lg:inline-flex transition-colors" className='hidden transition-colors lg:inline-flex'
/> />
)} )}
</div> </div>
@ -105,18 +99,21 @@ export default function NavBarItem({ item }: Props) {
<div <div
className={`${ className={`${
!isDropdownActive ? 'lg:hidden' : '' !isDropdownActive ? 'lg:hidden' : ''
} text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:left-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-lg lg:dark:bg-dark-900 dark:border-dark-700`} } border-b border-slate-100 text-sm lg:absolute lg:left-0 lg:top-full lg:z-20 lg:min-w-full lg:rounded-xl lg:border lg:border-slate-200 lg:bg-white lg:shadow-lg dark:border-dark-700 dark:bg-dark-900`}
>
<ClickOutside
onClickOutside={() => setIsDropdownActive(false)}
excludedElements={[excludedRef]}
> >
<ClickOutside onClickOutside={() => setIsDropdownActive(false)} excludedElements={[excludedRef]}>
<NavBarMenuList menu={item.menu} /> <NavBarMenuList menu={item.menu} />
</ClickOutside> </ClickOutside>
</div> </div>
)} )}
</> </>
) );
if (item.isDivider) { if (item.isDivider) {
return <BaseDivider navBar /> return <BaseDivider navBar />;
} }
if (item.href) { if (item.href) {
@ -124,8 +121,12 @@ export default function NavBarItem({ item }: Props) {
<Link href={item.href} target={item.target} className={componentClass}> <Link href={item.href} target={item.target} className={componentClass}>
{NavBarItemComponentContents} {NavBarItemComponentContents}
</Link> </Link>
) );
} }
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div> return (
<div className={componentClass} ref={excludedRef}>
{NavBarItemComponentContents}
</div>
);
} }

View File

@ -1,12 +1,11 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react';
import { useAppSelector } from '../stores/hooks'
type Props = { type Props = {
display?: string display?: string;
useMargin?: boolean useMargin?: boolean;
children?: ReactNode children?: ReactNode;
onClick?: (e: React.MouseEvent) => void onClick?: (e: React.MouseEvent) => void;
} };
export default function NavBarItemPlain({ export default function NavBarItemPlain({
display = 'flex', display = 'flex',
@ -14,17 +13,14 @@ export default function NavBarItemPlain({
onClick, onClick,
children, children,
}: Props) { }: Props) {
const navBarItemLabelStyle = useAppSelector((state) => state.style.navBarItemLabelStyle) const spacing = useMargin ? 'mx-3' : 'px-3';
const navBarItemLabelHoverStyle = useAppSelector((state) => state.style.navBarItemLabelHoverStyle)
const classBase = 'items-center cursor-pointer dark:text-white dark:hover:text-slate-400'
const classAddon = `${display} ${navBarItemLabelStyle} ${navBarItemLabelHoverStyle} ${
useMargin ? 'my-2 mx-3' : 'py-2 px-3'
}`
return ( return (
<div className={`${classBase} ${classAddon}`} onClick={onClick}> <div
className={`${display} ${spacing} h-10 cursor-pointer items-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-white dark:hover:bg-dark-700 dark:hover:text-slate-100`}
onClick={onClick}
>
{children} {children}
</div> </div>
) );
} }

View File

@ -1,13 +1,10 @@
import React from 'react'; import React from 'react';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useAppSelector } from '../stores/hooks';
const Search = () => { const Search = () => {
const router = useRouter(); const router = useRouter();
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const validateSearch = (value) => { const validateSearch = (value) => {
let error; let error;
if (!value) { if (!value) {
@ -17,6 +14,7 @@ const Search = () => {
} }
return error; return error;
}; };
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
@ -31,20 +29,23 @@ const Search = () => {
validateOnChange={false} validateOnChange={false}
> >
{({ errors, touched, values }) => ( {({ errors, touched, values }) => (
<Form style={{width: '300px'}} > <Form className='relative w-[340px] max-w-[44vw]'>
<Field <Field
id='search' id='search'
name='search' name='search'
validate={validateSearch} validate={validateSearch}
placeholder='Search' placeholder='Search accounts, orders, SKUs'
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`} className='h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm text-slate-900 outline-none transition focus:border-emerald-500 focus:bg-white focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-900 dark:text-white dark:placeholder-dark-600'
/> />
{errors.search && touched.search && values.search.length < 2 ? ( {errors.search && touched.search && values.search.length < 2 ? (
<div className='text-red-500 text-sm ml-2 absolute'>{errors.search}</div> <div className='absolute left-2 top-11 text-xs font-semibold text-red-500'>
{errors.search}
</div>
) : null} ) : null}
</Form> </Form>
)} )}
</Formik> </Formik>
); );
}; };
export default Search; export default Search;

View File

@ -1,10 +1,14 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react';
import { containerMaxW } from '../config' import { containerMaxW } from '../config';
type Props = { type Props = {
children: ReactNode children: ReactNode;
} };
export default function SectionMain({ children }: Props) { export default function SectionMain({ children }: Props) {
return <section className={`p-6 ${containerMaxW}`}>{children}</section> return (
<section className={`px-5 py-6 lg:px-8 lg:py-7 ${containerMaxW}`}>
{children}
</section>
);
} }

View File

@ -1,29 +1,51 @@
import { mdiCog } from '@mdi/js' import { mdiCog } from '@mdi/js';
import React, { Children, ReactNode } from 'react' import React, { Children, ReactNode } from 'react';
import BaseButton from './BaseButton' import BaseButton from './BaseButton';
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon';
import IconRounded from './IconRounded'
import { humanize } from '../helpers/humanize'; import { humanize } from '../helpers/humanize';
type Props = { type Props = {
icon: string icon: string;
title: string title: string;
main?: boolean main?: boolean;
children?: ReactNode children?: ReactNode;
} };
export default function SectionTitleLineWithButton({ icon, title, main = false, children }: Props) { export default function SectionTitleLineWithButton({
const hasChildren = !!Children.count(children) icon,
title,
main = false,
children,
}: Props) {
const hasChildren = !!Children.count(children);
return ( return (
<section className={`${main ? '' : 'pt-6'} mb-6 flex items-center justify-between`}> <section
<div className="flex items-center justify-start"> className={`${
{icon && main && <IconRounded icon={icon} color="light" className="mr-3" bg />} main ? '' : 'pt-6'
{icon && !main && <BaseIcon path={icon} className="mr-2" size="20" />} } mb-6 flex flex-col gap-4 border-b border-slate-200 pb-5 sm:flex-row sm:items-center sm:justify-between`}
<h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1> >
<div className='flex items-center justify-start gap-3'>
{icon && (
<span className='inline-flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-200 bg-emerald-50 text-emerald-700'>
<BaseIcon path={icon} size='22' />
</span>
)}
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
Supplier operations
</p>
<h1
className={`mt-1 font-semibold leading-tight text-slate-950 dark:text-white ${
main ? 'text-3xl' : 'text-2xl'
}`}
>
{humanize(title)}
</h1>
</div>
</div> </div>
{children} {children}
{!hasChildren && <BaseButton icon={mdiCog} color="whiteDark" />} {!hasChildren && <BaseButton icon={mdiCog} color='whiteDark' />}
</section> </section>
) );
} }

Binary file not shown.

View File

@ -8,8 +8,8 @@
} }
tr { tr {
@apply max-w-full block relative border-b-4 border-gray-100 @apply max-w-full block relative border-b border-slate-200
lg:table-row lg:border-b-0 dark:border-slate-800; lg:table-row dark:border-slate-800;
} }
tr:last-child { tr:last-child {
@ -17,11 +17,11 @@
} }
td:not(:first-child) { td:not(:first-child) {
@apply lg:border-l lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-gray-100 lg:dark:border-slate-700; @apply lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-slate-100 lg:dark:border-slate-700;
} }
th { th {
@apply lg:text-left lg:p-3 border-b; @apply lg:text-left lg:px-4 lg:py-3 border-b border-slate-200 bg-slate-50 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500;
} }
th.sortable { th.sortable {
@ -32,20 +32,20 @@
transition: all 1s; transition: all 1s;
position: absolute; position: absolute;
content: "↕"; content: '↕';
margin-left: 1rem; margin-left: 1rem;
} }
th.sortable.asc:hover:after { th.sortable.asc:hover:after {
content: "↑"; content: '↑';
} }
th.sortable.desc:hover:after { th.sortable.desc:hover:after {
content: "↓"; content: '↓';
} }
td { td {
@apply flex justify-between text-right py-3 px-4 align-top border-b border-gray-100 @apply flex justify-between text-right py-3 px-4 align-top border-b border-slate-100
lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800 dark:text-white; lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800 dark:text-white;
} }
@ -53,12 +53,13 @@
@apply border-b-0; @apply border-b-0;
} }
tbody tr, tbody tr:nth-child(odd) { tbody tr,
@apply lg:hover:bg-pavitra-300/70; tbody tr:nth-child(odd) {
@apply bg-white lg:hover:bg-emerald-50/70;
} }
tbody tr:nth-child(even) { tbody tr:nth-child(even) {
@apply lg:bg-pavitra-300 dark:bg-pavitra-300/70; @apply lg:bg-slate-50/60 dark:bg-dark-900;
} }
td:before { td:before {
@ -67,16 +68,16 @@
} }
tbody tr td { tbody tr td {
@apply text-sm font-normal text-pavitra-900 dark:text-white; @apply text-sm font-normal text-slate-700 dark:text-white;
} }
.datagrid--table, .MuiDataGrid-root { .datagrid--table,
@apply rounded border-none !important; .MuiDataGrid-root {
@apply rounded-xl border-none !important;
} }
.datagrid--header { .datagrid--header {
@apply uppercase !important; @apply uppercase tracking-[0.12em] bg-slate-50 text-slate-500 !important;
} }
.datagrid--header, .datagrid--header,
@ -95,10 +96,9 @@
} }
.datagrid--row { .datagrid--row {
@apply even:bg-gray-100 dark:even:bg-[#1B1D22] dark:odd:bg-dark-900 !important; @apply even:bg-slate-50 hover:bg-emerald-50/70 dark:even:bg-[#1B1D22] dark:odd:bg-dark-900 !important;
} }
.datagrid--table .MuiTablePagination-root { .datagrid--table .MuiTablePagination-root {
@apply dark:text-white; @apply dark:text-white;
} }
@ -112,6 +112,6 @@
} }
.MuiButton-colorInherit { .MuiButton-colorInherit {
@apply text-blue-600 dark:text-dark-700 !important; @apply text-emerald-700 dark:text-dark-700 !important;
} }
} }

Binary file not shown.

View File

@ -1,114 +1,132 @@
import React, { ReactNode, useEffect, useState } from 'react' import React, { ReactNode, useEffect, 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';
import menuNavBar from '../menuNavBar' import menuNavBar from '../menuNavBar';
import BaseIcon from '../components/BaseIcon' import BaseIcon from '../components/BaseIcon';
import NavBar from '../components/NavBar' import NavBar from '../components/NavBar';
import NavBarItemPlain from '../components/NavBarItemPlain' import NavBarItemPlain from '../components/NavBarItemPlain';
import AsideMenu from '../components/AsideMenu' import AsideMenu from '../components/AsideMenu';
import FooterBar from '../components/FooterBar' import FooterBar from '../components/FooterBar';
import { useAppDispatch, useAppSelector } from '../stores/hooks' import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Search from '../components/Search'; import Search from '../components/Search';
import { useRouter } from 'next/router' import { useRouter } from 'next/router';
import {findMe, logoutUser} from "../stores/authSlice"; import { findMe, logoutUser } from '../stores/authSlice';
import {hasPermission} from "../helpers/userPermissions";
import { hasPermission } from '../helpers/userPermissions';
type Props = { type Props = {
children: ReactNode children: ReactNode;
permission?: string permission?: string;
};
}
export default function LayoutAuthenticated({ export default function LayoutAuthenticated({
children, children,
permission permission,
}: Props) { }: Props) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch();
const router = useRouter() const router = useRouter();
const { token, currentUser } = useAppSelector((state) => state.auth) const { token, currentUser } = useAppSelector((state) => state.auth);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor); const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
let localToken let localToken;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Perform localStorage action // Perform localStorage action
localToken = localStorage.getItem('token') localToken = localStorage.getItem('token');
} }
const isTokenValid = () => { const isTokenValid = () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) {
return false;
}
const date = new Date().getTime() / 1000; const date = new Date().getTime() / 1000;
const data = jwt.decode(token); const data = jwt.decode(token) as { exp?: number } | null;
if (!data) return; if (!data) {
return date < data.exp; return false;
}
return !!data.exp && date < data.exp;
}; };
useEffect(() => { useEffect(() => {
dispatch(findMe());
if (!isTokenValid()) { if (!isTokenValid()) {
dispatch(logoutUser()); dispatch(logoutUser());
router.push('/login'); router.push('/login');
return;
} }
}, [token, localToken]);
dispatch(findMe());
}, [token, localToken, router.pathname, router.asPath]);
useEffect(() => { useEffect(() => {
if (!permission || !currentUser) return; if (!permission || !currentUser) {
return;
}
if (!hasPermission(currentUser, permission)) router.push('/error'); if (!hasPermission(currentUser, permission)) {
router.push('/error');
}
}, [currentUser, permission]); }, [currentUser, permission]);
const isPermissionDenied =
!!permission && !!currentUser && !hasPermission(currentUser, permission);
const darkMode = useAppSelector((state) => state.style.darkMode) const darkMode = useAppSelector((state) => state.style.darkMode);
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false);
const [isAsideLgActive, setIsAsideLgActive] = useState(false) const [isAsideLgActive, setIsAsideLgActive] = useState(false);
useEffect(() => { useEffect(() => {
const handleRouteChangeStart = () => { const handleRouteChangeStart = () => {
setIsAsideMobileExpanded(false) setIsAsideMobileExpanded(false);
setIsAsideLgActive(false) setIsAsideLgActive(false);
} };
router.events.on('routeChangeStart', handleRouteChangeStart) router.events.on('routeChangeStart', handleRouteChangeStart);
// If the component is unmounted, unsubscribe // If the component is unmounted, unsubscribe
// from the event with the `off` method: // from the event with the `off` method:
return () => { return () => {
router.events.off('routeChangeStart', handleRouteChangeStart) router.events.off('routeChangeStart', handleRouteChangeStart);
} };
}, [router.events, dispatch]) }, [router.events, dispatch]);
const layoutAsidePadding = 'xl:pl-72';
const layoutAsidePadding = 'xl:pl-60'
return ( return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}> <div
className={`${
darkMode ? 'dark' : ''
} overflow-hidden lg:overflow-visible`}
>
<div <div
className={`${layoutAsidePadding} ${ className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : '' isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`} } pt-16 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
> >
<NavBar <NavBar
menu={menuNavBar} menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`} className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
}`}
> >
<NavBarItemPlain <NavBarItemPlain
display="flex lg:hidden" display='flex lg:hidden'
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)} onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
> >
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" /> <BaseIcon
path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger}
size='24'
/>
</NavBarItemPlain> </NavBarItemPlain>
<NavBarItemPlain <NavBarItemPlain
display="hidden lg:flex xl:hidden" display='hidden lg:flex xl:hidden'
onClick={() => setIsAsideLgActive(true)} onClick={() => setIsAsideLgActive(true)}
> >
<BaseIcon path={mdiMenu} size="24" /> <BaseIcon path={mdiMenu} size='24' />
</NavBarItemPlain> </NavBarItemPlain>
<NavBarItemPlain useMargin> <NavBarItemPlain useMargin>
<Search /> <Search />
@ -120,9 +138,11 @@ export default function LayoutAuthenticated({
menu={menuAside} menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)} onAsideLgClose={() => setIsAsideLgActive(false)}
/> />
{children} <main className='min-h-[calc(100vh-4rem)]'>
<FooterBar>Hand-crafted & Made with </FooterBar> {!isPermissionDenied && children}
</main>
<FooterBar>Northstar Foodservice supplier operations</FooterBar>
</div> </div>
</div> </div>
) );
} }

View File

@ -0,0 +1,138 @@
import {
mdiCartOutline,
mdiFileDocumentOutline,
mdiHistory,
mdiLogout,
mdiMapMarkerOutline,
mdiPackageVariantClosed,
} from "@mdi/js";
import jwt from "jsonwebtoken";
import { useRouter } from "next/router";
import React, { ReactNode, useEffect } from "react";
import BaseIcon from "../components/BaseIcon";
import { useAppDispatch, useAppSelector } from "../stores/hooks";
import { findMe, logoutUser } from "../stores/authSlice";
type Props = {
children: ReactNode;
};
const navItems = [
{ label: "Catalog", href: "#catalog", icon: mdiCartOutline },
{ label: "PO draft", href: "#purchase-order", icon: mdiFileDocumentOutline },
{ label: "Orders", href: "#orders", icon: mdiHistory },
{ label: "Samples", href: "#samples", icon: mdiPackageVariantClosed },
];
const isStoredTokenValid = (token: string | null) => {
if (!token) {
return false;
}
const decodedToken = jwt.decode(token) as { exp?: number } | null;
if (!decodedToken?.exp) {
return false;
}
return new Date().getTime() / 1000 < decodedToken.exp;
};
export default function LayoutBuyerPortal({ children }: Props) {
const dispatch = useAppDispatch();
const router = useRouter();
const { currentUser } = useAppSelector((state) => state.auth);
useEffect(() => {
if (!router.isReady) {
return;
}
const token = localStorage.getItem("token");
if (!isStoredTokenValid(token)) {
dispatch(logoutUser());
const returnTo = encodeURIComponent(router.asPath || "/buyer-portal/");
router.replace(`/buyer-login?returnTo=${returnTo}`);
return;
}
if (!currentUser) {
dispatch(findMe());
}
}, [currentUser, dispatch, router.isReady, router.asPath]);
const handleLogout = () => {
dispatch(logoutUser());
router.push("/buyer-login?returnTo=%2Fbuyer-portal%2F");
};
const buyerName =
[currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(" ") ||
currentUser?.email ||
"Purchasing team";
const buyerRole = currentUser?.app_role?.name || "Buyer account";
return (
<div className="min-h-screen bg-[#f6f3ec] text-slate-950">
<header className="sticky top-0 z-40 border-b border-stone-200 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-[1440px] flex-col gap-4 px-5 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<div className="flex items-center justify-between gap-4">
<a href="/" className="min-w-0">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-emerald-700">
Northstar Foodservice
</p>
<p className="truncate text-lg font-semibold text-slate-950">
Buyer Portal
</p>
</a>
<button
type="button"
onClick={handleLogout}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700 lg:hidden"
aria-label="Sign out"
>
<BaseIcon path={mdiLogout} size={18} />
</button>
</div>
<nav className="flex gap-2 overflow-x-auto pb-1 lg:pb-0">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
className="inline-flex shrink-0 items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-semibold text-slate-700 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-800"
>
<BaseIcon path={item.icon} size={16} />
{item.label}
</a>
))}
</nav>
<div className="hidden items-center gap-3 lg:flex">
<div className="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-semibold text-slate-700">
<BaseIcon path={mdiMapMarkerOutline} size={16} />
Harbor Table
</div>
<div className="max-w-[260px] rounded-full bg-slate-950 px-4 py-2 text-sm font-semibold text-white">
<span className="block truncate">{buyerName}</span>
<span className="block truncate text-[11px] font-semibold text-slate-300">
{buyerRole}
</span>
</div>
<button
type="button"
onClick={handleLogout}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 bg-white text-slate-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
aria-label="Sign out"
>
<BaseIcon path={mdiLogout} size={18} />
</button>
</div>
</div>
</header>
<main>{children}</main>
</div>
);
}

View File

@ -1,211 +1,217 @@
import * as icon from '@mdi/js'; import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces' import { MenuAsideItem } from './interfaces';
const menuAside: MenuAsideItem[] = [ const menuAside: MenuAsideItem[] = [
{ {
href: '/dashboard', href: '/dashboard',
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Operations dashboard',
}, },
{ {
href: '/buyer-portal', href: '/buyer-portal',
label: 'Buyer Portal', icon: icon.mdiCart,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment label: 'Buyer portal',
// @ts-ignore permissions: 'READ_BUYER_PORTAL',
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRODUCTS'
},
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
}, },
{ {
href: '/roles/roles-list', label: 'Customer operations',
label: 'Roles', icon: icon.mdiDomain,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment permissions: [
// @ts-ignore 'READ_ACCOUNTS',
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, 'READ_LOCATIONS',
permissions: 'READ_ROLES' 'READ_CONTACTS',
}, 'READ_PRICE_LISTS',
{ 'READ_ACCOUNT_PRICE_LISTS',
href: '/permissions/permissions-list', 'READ_QUOTES',
label: 'Permissions', 'READ_ORDERS',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment ],
// @ts-ignore menu: [
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
},
{ {
href: '/accounts/accounts-list', href: '/accounts/accounts-list',
label: 'Accounts', label: 'Accounts',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiDomain,
// @ts-ignore permissions: 'READ_ACCOUNTS',
icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ACCOUNTS'
}, },
{ {
href: '/locations/locations-list', href: '/locations/locations-list',
label: 'Locations', label: 'Ship-to locations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiMapMarker,
// @ts-ignore permissions: 'READ_LOCATIONS',
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LOCATIONS'
}, },
{ {
href: '/contacts/contacts-list', href: '/contacts/contacts-list',
label: 'Contacts', label: 'Contacts',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiAccountMultiple,
// @ts-ignore permissions: 'READ_CONTACTS',
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CONTACTS'
},
{
href: '/product_categories/product_categories-list',
label: 'Product categories',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRODUCT_CATEGORIES'
},
{
href: '/products/products-list',
label: 'Products',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiPackageVariantClosed' in icon ? icon['mdiPackageVariantClosed' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRODUCTS'
},
{
href: '/inventory_items/inventory_items-list',
label: 'Inventory items',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiWarehouse' in icon ? icon['mdiWarehouse' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_INVENTORY_ITEMS'
}, },
{ {
href: '/price_lists/price_lists-list', href: '/price_lists/price_lists-list',
label: 'Price lists', label: 'Price lists',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiCashMultiple,
// @ts-ignore permissions: 'READ_PRICE_LISTS',
icon: 'mdiCashMultiple' in icon ? icon['mdiCashMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRICE_LISTS'
},
{
href: '/price_list_items/price_list_items-list',
label: 'Price list items',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCash' in icon ? icon['mdiCash' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PRICE_LIST_ITEMS'
}, },
{ {
href: '/account_price_lists/account_price_lists-list', href: '/account_price_lists/account_price_lists-list',
label: 'Account price lists', label: 'Account pricing',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiLinkVariant,
// @ts-ignore permissions: 'READ_ACCOUNT_PRICE_LISTS',
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ACCOUNT_PRICE_LISTS'
},
{
href: '/carts/carts-list',
label: 'Carts',
// 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_CARTS'
},
{
href: '/cart_items/cart_items-list',
label: 'Cart items',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CART_ITEMS'
},
{
href: '/orders/orders-list',
label: 'Orders',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORDERS'
},
{
href: '/order_items/order_items-list',
label: 'Order items',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORDER_ITEMS'
},
{
href: '/shipments/shipments-list',
label: 'Shipments',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTruckDelivery' in icon ? icon['mdiTruckDelivery' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SHIPMENTS'
}, },
{ {
href: '/quotes/quotes-list', href: '/quotes/quotes-list',
label: 'Quotes', label: 'Quotes',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiFileDocumentOutline,
// @ts-ignore permissions: 'READ_QUOTES',
icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_QUOTES'
}, },
{ {
href: '/quote_items/quote_items-list', href: '/orders/orders-list',
label: 'Quote items', label: 'Orders',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiClipboardTextOutline,
// @ts-ignore permissions: 'READ_ORDERS',
icon: 'mdiFileDocumentEditOutline' in icon ? icon['mdiFileDocumentEditOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, },
permissions: 'READ_QUOTE_ITEMS' ],
},
{
label: 'Catalog and inventory',
icon: icon.mdiPackageVariantClosed,
permissions: [
'READ_PRODUCT_CATEGORIES',
'READ_PRODUCTS',
'READ_INVENTORY_ITEMS',
'READ_SAMPLE_REQUESTS',
'READ_SAVED_LISTS',
],
menu: [
{
href: '/product_categories/product_categories-list',
label: 'Categories',
icon: icon.mdiTagMultiple,
permissions: 'READ_PRODUCT_CATEGORIES',
},
{
href: '/products/products-list',
label: 'Products',
icon: icon.mdiPackageVariantClosed,
permissions: 'READ_PRODUCTS',
},
{
href: '/inventory_items/inventory_items-list',
label: 'Inventory',
icon: icon.mdiWarehouse,
permissions: 'READ_INVENTORY_ITEMS',
}, },
{ {
href: '/sample_requests/sample_requests-list', href: '/sample_requests/sample_requests-list',
label: 'Sample requests', label: 'Sample requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiPackageVariant,
// @ts-ignore permissions: 'READ_SAMPLE_REQUESTS',
icon: 'mdiPackageVariant' in icon ? icon['mdiPackageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SAMPLE_REQUESTS'
}, },
{ {
href: '/saved_lists/saved_lists-list', href: '/saved_lists/saved_lists-list',
label: 'Saved lists', label: 'Saved order guides',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiBookmarkMultiple,
// @ts-ignore permissions: 'READ_SAVED_LISTS',
icon: 'mdiBookmarkMultiple' in icon ? icon['mdiBookmarkMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, },
permissions: 'READ_SAVED_LISTS' ],
},
{
label: 'Fulfillment',
icon: icon.mdiTruckDelivery,
permissions: [
'READ_CARTS',
'READ_CART_ITEMS',
'READ_ORDER_ITEMS',
'READ_SHIPMENTS',
'READ_QUOTE_ITEMS',
'READ_PRICE_LIST_ITEMS',
'READ_SAVED_LIST_ITEMS',
],
menu: [
{
href: '/shipments/shipments-list',
label: 'Shipments',
icon: icon.mdiTruckDelivery,
permissions: 'READ_SHIPMENTS',
},
{
href: '/order_items/order_items-list',
label: 'Order lines',
icon: icon.mdiClipboardList,
permissions: 'READ_ORDER_ITEMS',
},
{
href: '/carts/carts-list',
label: 'Carts',
icon: icon.mdiCart,
permissions: 'READ_CARTS',
},
{
href: '/cart_items/cart_items-list',
label: 'Cart lines',
icon: icon.mdiFormatListBulleted,
permissions: 'READ_CART_ITEMS',
},
{
href: '/quote_items/quote_items-list',
label: 'Quote lines',
icon: icon.mdiFileDocumentEditOutline,
permissions: 'READ_QUOTE_ITEMS',
},
{
href: '/price_list_items/price_list_items-list',
label: 'Price lines',
icon: icon.mdiCash,
permissions: 'READ_PRICE_LIST_ITEMS',
}, },
{ {
href: '/saved_list_items/saved_list_items-list', href: '/saved_list_items/saved_list_items-list',
label: 'Saved list items', label: 'Saved list lines',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiPlaylistPlus,
// @ts-ignore permissions: 'READ_SAVED_LIST_ITEMS',
icon: 'mdiPlaylistPlus' in icon ? icon['mdiPlaylistPlus' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, },
permissions: 'READ_SAVED_LIST_ITEMS' ],
},
{
label: 'System',
icon: icon.mdiShieldAccountOutline,
permissions: [
'READ_USERS',
'READ_ROLES',
'READ_PERMISSIONS',
'READ_API_DOCS',
],
menu: [
{
href: '/users/users-list',
label: 'Users',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS',
},
{
href: '/roles/roles-list',
label: 'Roles',
icon: icon.mdiShieldAccountVariantOutline,
permissions: 'READ_ROLES',
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
icon: icon.mdiShieldAccountOutline,
permissions: 'READ_PERMISSIONS',
},
{
href: '/api-docs',
target: '_blank',
label: 'API docs',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS',
},
],
}, },
{ {
href: '/profile', href: '/profile',
label: 'Profile', label: 'Profile',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
];
export default menuAside;
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
},
]
export default menuAside

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,279 @@
import type { ReactElement } from "react";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useState } from "react";
import axios from "axios";
import jwt from "jsonwebtoken";
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=1800";
const sampleImage =
"https://images.pexels.com/photos/4198018/pexels-photo-4198018.jpeg?auto=compress&cs=tinysrgb&w=900";
const buyerCredentials = [
{
label: "Buyer admin",
role: "Customer Buyer Admin",
name: "Maria Alvarez",
email: "maria.alvarez@harbortable.com",
password: "a04a876c6d59",
note: "Purchasing lead with order, sample, quote, and location workflow access.",
},
{
label: "Buyer user",
role: "Customer Buyer",
name: "Owen Price",
email: "owen.price@harbortable.com",
password: "a04a876c6d59",
note: "Restaurant buyer focused on catalog, reorder history, and PO checkout.",
},
];
export default function BuyerLoginPage() {
const router = useRouter();
const [email, setEmail] = useState(buyerCredentials[0].email);
const [password, setPassword] = useState(buyerCredentials[0].password);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const getReturnTo = () => {
const value = Array.isArray(router.query.returnTo)
? router.query.returnTo[0]
: router.query.returnTo;
if (value && value.startsWith("/")) {
return value;
}
return "/buyer-portal/";
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
setErrorMessage("");
try {
const { data: token } = await axios.post("/auth/signin/local", {
email,
password,
});
const user = jwt.decode(token);
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
router.push(getReturnTo());
} catch (error: any) {
console.error("Buyer login failed", error);
setErrorMessage(
error?.response?.data ||
"Unable to sign in with those buyer credentials."
);
} finally {
setIsSubmitting(false);
}
};
const useCredentials = (credentials: typeof buyerCredentials[number]) => {
setEmail(credentials.email);
setPassword(credentials.password);
setErrorMessage("");
};
return (
<>
<Head>
<title>{getPageTitle("Buyer sign in")}</title>
<meta
name="description"
content="Buyer sign in for the Northstar Foodservice B2B supplier and distributor portal."
/>
</Head>
<main className="min-h-screen bg-stone-50 text-slate-950">
<section className="grid min-h-screen lg:grid-cols-[minmax(0,1.1fr)_520px]">
<div className="relative hidden overflow-hidden lg:block">
<img
src={heroImage}
alt="Restaurant table prepared for foodservice buyers"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-slate-950/80 via-slate-950/35 to-transparent" />
<div className="relative flex h-full flex-col justify-between p-12 text-white">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-200">
Northstar Foodservice
</p>
<h1 className="mt-5 max-w-2xl text-5xl font-semibold leading-tight">
Contract ordering for restaurant buyers
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-stone-100">
Browse account pricing, request samples, build purchase
orders, and reorder from history without chasing PDF sheets.
</p>
</div>
<div className="grid max-w-2xl gap-3 sm:grid-cols-3">
<div className="border-l-4 border-emerald-300 bg-slate-950/45 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.18em] text-stone-300">
Price file
</p>
<p className="mt-2 font-semibold">Northstar Contract</p>
</div>
<div className="border-l-4 border-amber-300 bg-slate-950/45 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.18em] text-stone-300">
Cutoff
</p>
<p className="mt-2 font-semibold">Today 3 PM</p>
</div>
<div className="border-l-4 border-sky-300 bg-slate-950/45 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.18em] text-stone-300">
Workflow
</p>
<p className="mt-2 font-semibold">Catalog to PO</p>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center px-6 py-10">
<div className="w-full max-w-md">
<a
href="/"
className="inline-flex text-sm font-semibold text-emerald-700"
>
Back to portal overview
</a>
<div className="mt-8 overflow-hidden border border-stone-200 bg-white shadow-sm">
<img
src={sampleImage}
alt="Sample-ready specialty ingredient"
className="h-44 w-full object-cover"
/>
<div className="p-6">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700">
Buyer sign in
</p>
<h2 className="mt-3 text-3xl font-semibold">
Open your order guide
</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">
Sign in to see contract pricing, saved order behavior,
sample requests, and purchase-order checkout.
</p>
<div className="mt-5 grid gap-3">
{buyerCredentials.map((credentials) => {
const isActive =
email === credentials.email &&
password === credentials.password;
return (
<button
key={credentials.email}
type="button"
onClick={() => useCredentials(credentials)}
className={`rounded-2xl border p-4 text-left transition ${
isActive
? "border-emerald-300 bg-emerald-50"
: "border-stone-200 bg-stone-50 hover:border-emerald-200 hover:bg-emerald-50"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-bold text-slate-950">
{credentials.label}
</p>
<p className="mt-1 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
{credentials.role}
</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 shadow-sm">
Use creds
</span>
</div>
<p className="mt-3 text-sm font-semibold text-slate-900">
{credentials.name}
</p>
<p className="mt-1 break-all font-mono text-xs text-slate-600">
{credentials.email}
</p>
<p className="mt-1 font-mono text-xs text-slate-500">
{credentials.password}
</p>
<p className="mt-3 text-xs leading-5 text-slate-500">
{credentials.note}
</p>
</button>
);
})}
</div>
{errorMessage && (
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
{errorMessage}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<label className="block">
<span className="text-sm font-semibold text-slate-900">
Email
</span>
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-2 h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
type="email"
autoComplete="email"
/>
</label>
<label className="block">
<span className="text-sm font-semibold text-slate-900">
Password
</span>
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-2 h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
type="password"
autoComplete="current-password"
/>
</label>
<button
type="submit"
disabled={isSubmitting}
className="h-12 w-full rounded-xl bg-emerald-500 px-5 text-sm font-bold text-slate-950 shadow-sm transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting
? "Signing in..."
: "Sign in to buyer portal"}
</button>
</form>
<div className="mt-5 rounded-2xl bg-stone-50 p-4 text-sm leading-7 text-slate-600">
Demo workspace is prefilled for the generated project. A
production supplier would connect this page to buyer
accounts, SSO, or invited customer contacts.
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</>
);
}
BuyerLoginPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -210,7 +210,7 @@ export default function HomePage() {
Login Login
</a> </a>
<a <a
href="/buyer-portal" href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
className="rounded-full bg-slate-950 px-5 py-2 text-white shadow-sm" className="rounded-full bg-slate-950 px-5 py-2 text-white shadow-sm"
> >
Open portal Open portal
@ -244,7 +244,7 @@ export default function HomePage() {
</p> </p>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
<a <a
href="/buyer-portal" href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
className="rounded-full bg-emerald-400 px-6 py-3 text-sm font-bold text-slate-950 shadow-lg" className="rounded-full bg-emerald-400 px-6 py-3 text-sm font-bold text-slate-950 shadow-lg"
> >
Open buyer portal Open buyer portal
@ -517,7 +517,7 @@ export default function HomePage() {
availability before the order reaches the distributor team. availability before the order reaches the distributor team.
</div> </div>
<a <a
href="/buyer-portal" href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
className="mt-4 block rounded-xl bg-emerald-500 px-5 py-4 text-center text-sm font-bold text-slate-950" 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 Continue in real buyer portal
@ -627,7 +627,7 @@ export default function HomePage() {
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<a <a
href="/buyer-portal" href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
className="rounded-xl bg-emerald-400 px-5 py-4 text-center text-sm font-bold text-slate-950" className="rounded-xl bg-emerald-400 px-5 py-4 text-center text-sm font-bold text-slate-950"
> >
Open buyer portal Open buyer portal

View File

@ -1,273 +1,347 @@
import React, { useEffect, 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 BaseButton from '../components/BaseButton'; import Link from 'next/link';
import CardBox from '../components/CardBox';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { toast, ToastContainer } from 'react-toastify';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { findMe, loginUser, resetAction } from '../stores/authSlice'; import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify"; const heroImage =
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' 'https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1800';
const teamImage =
'https://images.pexels.com/photos/6169659/pexels-photo-6169659.jpeg?auto=compress&cs=tinysrgb&w=900';
const staffCredentials = [
{
label: 'Supplier admin',
role: 'Administrator',
name: 'Northstar Operations',
email: 'admin@flatlogic.com',
password: '3ab96dc1',
note: 'Full access to catalog, buyer accounts, price lists, orders, samples, and fulfillment workflows.',
},
{
label: 'Demo user',
role: 'User',
name: 'Standard SaaS user',
email: 'client@hello.com',
password: 'a04a876c6d59',
note: 'Lightweight generated SaaS user for smoke checks outside the buyer-specific workspace.',
},
];
export default function Login() { export default function Login() {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor); const [email, setEmail] = useState(staffCredentials[0].email);
const iconsColor = useAppSelector((state) => state.style.iconsColor); const [password, setPassword] = useState(staffCredentials[0].password);
const notify = (type, msg) => toast(msg, { type }); const [remember, setRemember] = useState(true);
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 [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( const {
(state) => state.auth, currentUser,
); isFetching,
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com', errorMessage,
password: '3ab96dc1', token,
remember: true }) notify: notifyState,
} = useAppSelector((state) => state.auth);
const title = 'B2B Distributor Portal' const title = 'B2B Distributor Portal';
// Fetch Pexels image/video
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
// Fetch user data
useEffect(() => { useEffect(() => {
if (token) { if (token) {
dispatch(findMe()); dispatch(findMe());
} }
}, [token, dispatch]); }, [token, dispatch]);
// Redirect to dashboard if user is logged in
useEffect(() => { useEffect(() => {
if (currentUser?.id) { if (currentUser?.id) {
if (
['Customer Buyer Admin', 'Customer Buyer'].includes(
currentUser.app_role?.name,
)
) {
router.push('/buyer-portal');
return;
}
router.push('/dashboard'); router.push('/dashboard');
} }
}, [currentUser?.id, router]); }, [currentUser?.id, router]);
// Show error message if there is one
useEffect(() => { useEffect(() => {
if (errorMessage) { if (errorMessage) {
notify('error', errorMessage) toast(errorMessage, { type: 'error' });
} }
}, [errorMessage]);
}, [errorMessage])
// Show notification if there is one
useEffect(() => { useEffect(() => {
if (notifyState?.showNotification) { if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification) toast(notifyState?.textNotification, { type: 'success' });
dispatch(resetAction()); dispatch(resetAction());
} }
}, [notifyState?.showNotification]) }, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
const togglePasswordVisibility = () => { const applyCredentials = (credentials: (typeof staffCredentials)[number]) => {
setShowPassword(!showPassword); setEmail(credentials.email);
setPassword(credentials.password);
setRemember(true);
}; };
const handleSubmit = async (value) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const {remember, ...rest} = value event.preventDefault();
await dispatch(loginUser(rest)); await dispatch(loginUser({ email, password }));
};
const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({
...prev,
email : target.innerText.trim(),
password: target.dataset.password ?? '',
}));
};
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('Login')}</title> <title>{getPageTitle('Login')}</title>
<meta
name='description'
content='Staff sign in for the Northstar Foodservice supplier and distributor portal.'
/>
</Head> </Head>
<SectionFullScreen bg='violet'> <main className='min-h-screen bg-stone-50 text-slate-950'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}> <section className='grid min-h-screen lg:grid-cols-[minmax(0,1.1fr)_520px]'>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} <div className='relative hidden overflow-hidden lg:block'>
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null} <img
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> src={heroImage}
alt='Fresh foodservice ingredients ready for distributor operations'
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'> className='absolute inset-0 h-full w-full object-cover'
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row text-gray-500 justify-between'>
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="3ab96dc1"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>3ab96dc1</code>{' / '}
to login as Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="a04a876c6d59"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>a04a876c6d59</code>{' / '}
to login as User</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/> />
<div className='absolute inset-0 bg-gradient-to-r from-slate-950/80 via-slate-950/35 to-transparent' />
<div className='relative flex h-full flex-col justify-between p-12 text-white'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-emerald-200'>
Northstar Foodservice
</p>
<h1 className='mt-5 max-w-2xl text-5xl font-semibold leading-tight'>
Staff workspace for distributor operations
</h1>
<p className='mt-5 max-w-xl text-base leading-8 text-stone-100'>
Manage account-specific pricing, product catalogs, buyer
orders, sample requests, saved lists, and fulfillment handoff
from one generated SaaS admin.
</p>
</div>
<div className='grid max-w-2xl gap-3 sm:grid-cols-3'>
<div className='border-l-4 border-emerald-300 bg-slate-950/45 p-4 backdrop-blur'>
<p className='text-xs uppercase tracking-[0.18em] text-stone-300'>
Catalog
</p>
<p className='mt-2 font-semibold'>Contract SKUs</p>
</div>
<div className='border-l-4 border-amber-300 bg-slate-950/45 p-4 backdrop-blur'>
<p className='text-xs uppercase tracking-[0.18em] text-stone-300'>
Accounts
</p>
<p className='mt-2 font-semibold'>Buyer pricing</p>
</div>
<div className='border-l-4 border-sky-300 bg-slate-950/45 p-4 backdrop-blur'>
<p className='text-xs uppercase tracking-[0.18em] text-stone-300'>
Workflow
</p>
<p className='mt-2 font-semibold'>Orders to dock</p>
</div>
</div>
</div> </div>
</div> </div>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <div className='flex items-center justify-center px-6 py-10'>
<Formik <div className='w-full max-w-md'>
initialValues={initialValues} <div className='flex items-center justify-between gap-4'>
enableReinitialize <Link
onSubmit={(values) => handleSubmit(values)} href='/'
className='inline-flex text-sm font-semibold text-emerald-700'
> >
<Form> Back to portal overview
<FormField </Link>
label='Login' <Link
help='Please enter your login'> href='/buyer-login/?returnTo=%2Fbuyer-portal%2F'
<Field name='email' /> className='text-sm font-semibold text-slate-500 hover:text-emerald-700'
</FormField> >
Buyer login
</Link>
</div>
<div className='relative'> <div className='mt-8 overflow-hidden border border-stone-200 bg-white shadow-sm'>
<FormField <img
label='Password' src={teamImage}
help='Please enter your password'> alt='Foodservice operations team preparing restaurant orders'
<Field name='password' type={showPassword ? 'text' : 'password'} /> className='h-36 w-full object-cover sm:h-44'
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
className='text-gray-500 hover:text-gray-700'
size={20}
path={showPassword ? mdiEyeOff : mdiEye}
/> />
<div className='p-5 sm:p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700'>
Staff sign in
</p>
<h2 className='mt-3 text-2xl font-semibold sm:text-3xl'>
Open the supplier admin
</h2>
<p className='mt-3 text-sm leading-6 text-slate-600 sm:leading-7'>
Sign in to manage the operational side of the B2B
distributor portal: products, customer accounts, contract
pricing, orders, quotes, and sample workflows.
</p>
<div className='mt-4 grid gap-2 sm:mt-5 sm:gap-3'>
{staffCredentials.map((credentials) => {
const isActive =
email === credentials.email &&
password === credentials.password;
return (
<button
key={credentials.email}
type='button'
onClick={() => applyCredentials(credentials)}
className={`rounded-2xl border p-3 text-left transition sm:p-4 ${
isActive
? 'border-emerald-300 bg-emerald-50'
: 'border-stone-200 bg-stone-50 hover:border-emerald-200 hover:bg-emerald-50'
}`}
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-sm font-bold text-slate-950'>
{credentials.label}
</p>
<p className='mt-1 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700'>
{credentials.role}
</p>
</div> </div>
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 shadow-sm'>
Use creds
</span>
</div>
<p className='mt-3 text-sm font-semibold text-slate-900'>
{credentials.name}
</p>
<p className='mt-1 break-all font-mono text-xs text-slate-600'>
{credentials.email}
</p>
<p className='mt-1 font-mono text-xs text-slate-500'>
{credentials.password}
</p>
<p className='mt-3 hidden text-xs leading-5 text-slate-500 sm:block'>
{credentials.note}
</p>
</button>
);
})}
</div> </div>
<div className={'flex justify-between'}> {errorMessage && (
<FormCheckRadio type='checkbox' label='Remember'> <div className='mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800'>
<Field type='checkbox' name='remember' /> {errorMessage}
</FormCheckRadio> </div>
)}
<Link className={`${textColor} text-blue-600`} href={'/forgot'}> <form className='mt-6 space-y-4' onSubmit={handleSubmit}>
<label className='block'>
<span className='text-sm font-semibold text-slate-900'>
Email
</span>
<input
name='email'
value={email}
onChange={(event) => setEmail(event.target.value)}
type='email'
autoComplete='email'
className='mt-2 h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100'
/>
</label>
<label className='block'>
<span className='text-sm font-semibold text-slate-900'>
Password
</span>
<div className='mt-2 flex h-12 overflow-hidden rounded-xl border border-stone-300 bg-white focus-within:border-emerald-500 focus-within:ring-2 focus-within:ring-emerald-100'>
<input
name='password'
value={password}
onChange={(event) => setPassword(event.target.value)}
type={showPassword ? 'text' : 'password'}
autoComplete='current-password'
className='min-w-0 flex-1 border-0 bg-transparent px-4 text-slate-950 outline-none'
/>
<button
type='button'
onClick={() => setShowPassword((value) => !value)}
className='px-4 text-sm font-semibold text-slate-500 hover:text-emerald-700'
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
</label>
<div className='flex items-center justify-between gap-4 text-sm'>
<label className='inline-flex items-center gap-2 font-semibold text-slate-600'>
<input
type='checkbox'
checked={remember}
onChange={(event) =>
setRemember(event.target.checked)
}
className='h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500'
/>
Remember
</label>
<Link
href='/forgot'
className='font-semibold text-emerald-700'
>
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
<BaseDivider /> <button
<BaseButtons>
<BaseButton
className={'w-full'}
type='submit' type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='info'
disabled={isFetching} disabled={isFetching}
/> className='h-12 w-full rounded-xl bg-emerald-500 px-5 text-sm font-bold text-slate-950 shadow-sm transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-70'
</BaseButtons> >
<br /> {isFetching ? 'Signing in...' : 'Sign in to admin'}
<p className={'text-center'}> </button>
Dont have an account yet?{' '} </form>
<Link className={`${textColor}`} href={'/register'}>
<div className='mt-5 rounded-2xl bg-stone-50 p-4 text-sm leading-7 text-slate-600'>
Need buyer access instead? Use the dedicated buyer login for
customer-side catalog, reorder, sample, and PO workflows.
</div>
<p className='mt-5 text-center text-sm text-slate-500'>
Do not have an account yet?{' '}
<Link
className='font-semibold text-emerald-700'
href='/register'
>
New Account New Account
</Link> </Link>
</p> </p>
</Form>
</Formik>
</CardBox>
</div> </div>
</div> </div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <div className='mt-6 flex justify-center gap-4 text-xs text-slate-500'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p> <span>© 2026 {title}</span>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> <Link
href='/privacy-policy/'
className='hover:text-emerald-700'
>
Privacy Policy Privacy Policy
</Link> </Link>
</div> </div>
<ToastContainer />
</div> </div>
</div>
</section>
</main>
<ToastContainer />
</>
); );
} }

View File

@ -1,42 +1,37 @@
/* eslint-disable @next/next/no-img-element */
import { import {
mdiAccountCircle,
mdiAccountTie,
mdiChartTimelineVariant, mdiChartTimelineVariant,
mdiCheckCircleOutline,
mdiEmailOutline,
mdiLockReset,
mdiPhoneOutline,
mdiShieldAccountOutline,
mdiStoreCogOutline,
mdiUpload, mdiUpload,
} from '@mdi/js'; } from '@mdi/js';
import Head from 'next/head'; import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import { ToastContainer, toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import FormImagePicker from '../components/FormImagePicker';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain'; import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import BaseButton from '../components/BaseButton';
import FormCheckRadio from '../components/FormCheckRadio';
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
import FormImagePicker from '../components/FormImagePicker';
import { SwitchField } from '../components/SwitchField';
import { SelectField } from '../components/SelectField'; import { SelectField } from '../components/SelectField';
import { SwitchField } from '../components/SwitchField';
import { update, fetch } from '../stores/users/usersSlice'; import { findMe } from '../stores/authSlice';
import { getPageTitle } from '../config';
import { update } from '../stores/users/usersSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import {findMe} from "../stores/authSlice";
const EditUsers = () => {
const { currentUser, isFetching, token } = useAppSelector(
(state) => state.auth,
);
const router = useRouter();
const dispatch = useAppDispatch();
const notify = (type, msg) => toast(msg, { type });
const initVals = { const initVals = {
firstName: '', firstName: '',
lastName: '', lastName: '',
@ -45,60 +40,279 @@ const EditUsers = () => {
app_role: '', app_role: '',
disabled: false, disabled: false,
avatar: [], avatar: [],
password: '' password: '',
}; };
const fieldClass =
'h-12 rounded-xl border border-slate-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100';
const Profile = () => {
const dispatch = useAppDispatch();
const { currentUser, isFetching } = useAppSelector((state) => state.auth);
const [initialValues, setInitialValues] = useState(initVals); const [initialValues, setInitialValues] = useState(initVals);
useEffect(() => { useEffect(() => {
if (currentUser?.id && typeof currentUser === 'object') { if (currentUser?.id && typeof currentUser === 'object') {
const newInitialVal = { ...initVals }; const nextInitialValues = { ...initVals };
Object.keys(initVals).forEach( Object.keys(initVals).forEach((fieldName) => {
(el) => (newInitialVal[el] = currentUser[el]), nextInitialValues[fieldName] =
); currentUser[fieldName] ?? initVals[fieldName];
});
setInitialValues(newInitialVal); setInitialValues(nextInitialValues);
} }
}, [currentUser]); }, [currentUser]);
const handleSubmit = async (data) => { const avatarUrl = currentUser?.avatar?.[0]?.publicUrl;
const roleName = currentUser?.app_role?.name || 'Role pending';
const fullName = useMemo(() => {
const name = [initialValues.firstName, initialValues.lastName]
.filter(Boolean)
.join(' ');
return name || currentUser?.email || 'Workspace user';
}, [currentUser?.email, initialValues.firstName, initialValues.lastName]);
const initials = useMemo(() => {
return fullName
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}, [fullName]);
async function handleSubmit(data) {
if (!currentUser?.id) {
throw new Error('Cannot update profile without current user id');
}
await dispatch(update({ id: currentUser.id, data })); await dispatch(update({ id: currentUser.id, data }));
await dispatch(findMe()); await dispatch(findMe());
await router.push('/users/users-list'); toast('Profile was updated!', { type: 'success' });
notify('success', 'Profile was updated!'); }
};
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Edit profile')}</title> <title>{getPageTitle('Profile')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton <SectionTitleLineWithButton
icon={mdiChartTimelineVariant} icon={mdiChartTimelineVariant}
title='Edit profile' title='Profile'
main main
> >
{''} <div className='flex flex-wrap gap-2'>
</SectionTitleLineWithButton> <BaseButton
<CardBox> href='/dashboard'
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}> color='info'
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8"> label='Operations dashboard'
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" /> />
</div> </div>
</div>} </SectionTitleLineWithButton>
<div className='grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]'>
<div className='space-y-5'>
<CardBox cardBoxClassName='p-0'>
<div className='bg-slate-950 p-6 text-white'>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-emerald-300'>
Northstar identity
</p>
<p className='mt-3 text-sm leading-6 text-slate-300'>
Profile details for the supplier operations workspace.
</p>
</div>
<div className='p-6'>
<div className='flex items-start gap-4'>
<div className='inline-flex h-24 w-24 flex-none items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 text-3xl font-semibold text-slate-700 shadow-sm'>
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${fullName} avatar`}
className='h-full w-full object-cover'
/>
) : (
initials || 'NS'
)}
</div>
<div className='min-w-0 pt-1'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700'>
Signed-in user
</p>
<h2 className='mt-2 break-words text-2xl font-semibold text-slate-950'>
{fullName}
</h2>
<p className='mt-2 break-all text-sm text-slate-500'>
{currentUser?.email || 'Email pending'}
</p>
</div>
</div>
<div className='mt-6 grid gap-3'>
<div className='rounded-xl border border-slate-200 bg-slate-50 p-4'>
<div className='flex items-center gap-3'>
<span className='inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700'>
<BaseIcon path={mdiShieldAccountOutline} size={22} />
</span>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
Access role
</p>
<p className='mt-1 font-semibold text-slate-950'>
{roleName}
</p>
</div>
</div>
</div>
<div className='rounded-xl border border-slate-200 bg-slate-50 p-4'>
<div className='flex items-center gap-3'>
<span className='inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700'>
<BaseIcon path={mdiCheckCircleOutline} size={22} />
</span>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
Account status
</p>
<p className='mt-1 font-semibold text-slate-950'>
{initialValues.disabled ? 'Disabled' : 'Active'}
</p>
</div>
</div>
</div>
</div>
</div>
</CardBox>
<CardBox>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
Workspace context
</p>
<div className='mt-5 space-y-4'>
<div className='flex gap-3'>
<span className='inline-flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-slate-50 text-emerald-700 shadow-sm'>
<BaseIcon path={mdiStoreCogOutline} size={22} />
</span>
<div>
<p className='font-semibold text-slate-950'>
Supplier operations
</p>
<p className='mt-1 text-sm leading-6 text-slate-500'>
This profile controls identity and access inside the
catalog, pricing, orders, and fulfillment admin.
</p>
</div>
</div>
<div className='flex gap-3'>
<span className='inline-flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-slate-50 text-emerald-700 shadow-sm'>
<BaseIcon path={mdiAccountTie} size={22} />
</span>
<div>
<p className='font-semibold text-slate-950'>
Buyer-facing responsibility
</p>
<p className='mt-1 text-sm leading-6 text-slate-500'>
Keep name, phone, and avatar current so buyer teams can
recognize account owners in the portal.
</p>
</div>
</div>
</div>
</CardBox>
</div>
<Formik <Formik
enableReinitialize enableReinitialize
initialValues={initialValues} initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form className='space-y-6'>
<FormField> <CardBox>
<div className='mb-6 flex flex-col gap-3 border-b border-slate-100 pb-5 md:flex-row md:items-start md:justify-between'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
Profile details
</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-950'>
Contact identity
</h3>
<p className='mt-2 max-w-2xl text-sm leading-6 text-slate-500'>
Update the information that appears in the supplier admin
and buyer-support workflows.
</p>
</div>
<div className='rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600'>
<span className='font-semibold text-slate-950'>Role:</span>{' '}
{roleName}
</div>
</div>
<div className='grid gap-6 lg:grid-cols-[minmax(0,1fr)_220px]'>
<div className='grid gap-x-4 md:grid-cols-2'>
<FormField label='First name'>
<Field <Field
label='Avatar' name='firstName'
placeholder='First name'
className={fieldClass}
/>
</FormField>
<FormField label='Last name'>
<Field
name='lastName'
placeholder='Last name'
className={fieldClass}
/>
</FormField>
<FormField label='Phone number' icons={[mdiPhoneOutline]}>
<Field
name='phoneNumber'
placeholder='Phone number'
className={fieldClass}
/>
</FormField>
<div className='md:col-span-2'>
<FormField label='Email' icons={[mdiEmailOutline]}>
<Field
name='email'
placeholder='Email'
disabled
className={fieldClass}
/>
</FormField>
</div>
</div>
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>
Avatar
</p>
<div className='mt-4 flex items-center gap-4 lg:block'>
<div className='inline-flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl bg-white text-2xl font-semibold text-slate-700 shadow-sm lg:h-28 lg:w-28'>
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${fullName} avatar preview`}
className='h-full w-full object-cover'
/>
) : (
initials || 'NS'
)}
</div>
<div className='min-w-0 flex-1 lg:mt-4'>
<Field
label='Upload avatar'
color='info' color='info'
icon={mdiUpload} icon={mdiUpload}
path={'users/avatar'} path='users/avatar'
name='avatar' name='avatar'
id='avatar' id='avatar'
schema={{ schema={{
@ -106,75 +320,110 @@ const EditUsers = () => {
formats: undefined, formats: undefined,
}} }}
component={FormImagePicker} component={FormImagePicker}
></Field> />
</FormField> </div>
<FormField label='First Name'> </div>
<Field name='firstName' placeholder='First Name' /> </div>
</FormField> </div>
</CardBox>
<FormField label='Last Name'> <CardBox>
<Field name='lastName' placeholder='Last Name' /> <div className='mb-6 border-b border-slate-100 pb-5'>
</FormField> <p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
Access and security
</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-950'>
Role, account state, and password
</h3>
<p className='mt-2 max-w-2xl text-sm leading-6 text-slate-500'>
Keep operational access intentional. These fields affect how
the user moves through buyer, catalog, order, and system
modules.
</p>
</div>
<FormField label='Phone Number'> <div className='grid gap-x-4 md:grid-cols-2'>
<Field name='phoneNumber' placeholder='Phone Number' /> <FormField label='App role' labelFor='app_role'>
</FormField>
<FormField label='E-Mail'>
<Field name='email' placeholder='E-Mail' disabled />
</FormField>
<FormField label='App Role' labelFor='app_role'>
<Field <Field
name='app_role' name='app_role'
id='app_role' id='app_role'
component={SelectField} component={SelectField}
options={initialValues.app_role} options={initialValues.app_role}
itemRef={'roles'} itemRef='roles'
showField={'name'} showField='name'
></Field> />
</FormField> </FormField>
<div className='rounded-xl border border-slate-200 bg-slate-50 p-4'>
<FormField label='Disabled' labelFor='disabled'> <FormField label='Disabled' labelFor='disabled'>
<Field <Field
name='disabled' name='disabled'
id='disabled' id='disabled'
component={SwitchField} component={SwitchField}
></Field>
</FormField>
<FormField
label="Password"
>
<Field
name="password"
placeholder="password"
/> />
</FormField> </FormField>
<p className='text-sm leading-6 text-slate-500'>
Disabling an account should be reserved for offboarding,
access review, or compromised credentials.
</p>
</div>
<BaseDivider /> <div className='md:col-span-2'>
<FormField label='New password' icons={[mdiLockReset]}>
<Field
name='password'
type='password'
placeholder='Optional new password'
className={fieldClass}
/>
</FormField>
</div>
</div>
</CardBox>
<CardBox>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div className='flex items-start gap-3'>
<span className='inline-flex h-11 w-11 flex-none items-center justify-center rounded-xl bg-emerald-50 text-emerald-700'>
<BaseIcon path={mdiAccountCircle} size={24} />
</span>
<div>
<p className='font-semibold text-slate-950'>
Save profile changes
</p>
<p className='mt-1 text-sm leading-6 text-slate-500'>
Changes are applied to this account and refreshed in the
current session.
</p>
</div>
</div>
<BaseButtons> <BaseButtons>
<BaseButton type='submit' color='info' label='Submit' /> <BaseButton
<BaseButton type='reset' color='info' outline label='Reset' /> type='submit'
color='info'
label={isFetching ? 'Saving...' : 'Save profile'}
disabled={isFetching}
/>
<BaseButton <BaseButton
type='reset' type='reset'
color='danger' color='info'
outline outline
label='Cancel' label='Reset'
onClick={() => router.push('/users/users-list')}
/> />
</BaseButtons> </BaseButtons>
</div>
</CardBox>
</Form> </Form>
</Formik> </Formik>
</CardBox> </div>
</SectionMain> </SectionMain>
</> </>
); );
}; };
EditUsers.getLayout = function getLayout(page: ReactElement) { Profile.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>; return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
}; };
export default EditUsers; export default Profile;

View File

@ -1,14 +1,14 @@
interface StyleObject { interface StyleObject {
aside: string aside: string;
asideScrollbars: string asideScrollbars: string;
asideBrand: string asideBrand: string;
asideMenuItem: string asideMenuItem: string;
asideMenuItemActive: string asideMenuItemActive: string;
asideMenuDropdown: string asideMenuDropdown: string;
navBarItemLabel: string navBarItemLabel: string;
navBarItemLabelHover: string navBarItemLabelHover: string;
navBarItemLabelActiveColor: string navBarItemLabelActiveColor: string;
overlay: string overlay: string;
activeLinkColor: string; activeLinkColor: string;
bgLayoutColor: string; bgLayoutColor: string;
iconsColor: string; iconsColor: string;
@ -25,34 +25,32 @@ interface StyleObject {
} }
export const white: StyleObject = { export const white: StyleObject = {
aside: 'bg-white dark:text-white', aside: 'bg-white text-slate-900 border-r border-slate-200 dark:text-white',
asideScrollbars: 'aside-scrollbars-light', asideScrollbars: 'aside-scrollbars-light',
asideBrand: '', asideBrand: 'border-b border-slate-200',
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800', asideMenuItem:
asideMenuItemActive: 'font-bold text-black dark:text-white', 'text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:text-white dark:hover:bg-dark-800',
asideMenuDropdown: 'bg-gray-100/75', asideMenuItemActive: 'font-semibold text-slate-950 dark:text-white',
navBarItemLabel: 'text-blue-600', asideMenuDropdown: 'bg-slate-50/80',
navBarItemLabelHover: 'hover:text-black', navBarItemLabel: 'text-slate-600',
navBarItemLabelActiveColor: 'text-black', navBarItemLabelHover: 'hover:text-slate-950',
navBarItemLabelActiveColor: 'text-slate-950',
overlay: 'from-white via-gray-100 to-white', overlay: 'from-white via-gray-100 to-white',
activeLinkColor: 'bg-gray-100/70', activeLinkColor: 'bg-emerald-50 ring-1 ring-emerald-200',
bgLayoutColor: 'bg-gray-50', bgLayoutColor: 'bg-slate-50',
iconsColor: 'text-blue-500', iconsColor: 'text-emerald-700',
cardsColor: 'bg-white', cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600', focusRingColor:
corners: 'rounded', 'focus:ring focus:ring-emerald-100 focus:border-emerald-500 focus:outline-none border-slate-300 dark:focus:ring-emerald-800 dark:focus:border-emerald-500',
cardsStyle: 'bg-white border border-pavitra-400', corners: 'rounded-xl',
linkColor: 'text-blue-600', cardsStyle: 'bg-white border border-slate-200 shadow-sm',
websiteHeder: 'border-b border-gray-200', linkColor: 'text-emerald-700',
borders: 'border-gray-200', websiteHeder: 'border-b border-slate-200',
shadow: '', borders: 'border-slate-200',
shadow: 'shadow-sm',
websiteSectionStyle: '', websiteSectionStyle: '',
textSecondary: 'text-gray-500', textSecondary: 'text-slate-500',
} };
export const dataGridStyles = { export const dataGridStyles = {
'& .MuiDataGrid-cell': { '& .MuiDataGrid-cell': {
@ -95,7 +93,8 @@ export const basic: StyleObject = {
bgLayoutColor: 'bg-gray-50', bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500', iconsColor: 'text-blue-500',
cardsColor: 'bg-white', cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600', focusRingColor:
'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
corners: 'rounded', corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400', cardsStyle: 'bg-white border border-pavitra-400',
linkColor: 'text-black', linkColor: 'text-black',
@ -104,4 +103,4 @@ export const basic: StyleObject = {
shadow: '', shadow: '',
websiteSectionStyle: '', websiteSectionStyle: '',
textSecondary: '', textSecondary: '',
} };