Autosave: 20260503-111326

This commit is contained in:
Flatlogic Bot 2026-05-03 11:13:21 +00:00
parent 677de2a77b
commit 44376dbb51
36 changed files with 1315 additions and 784 deletions

View File

@ -907,6 +907,8 @@ module.exports = class UsersDBApi {
{ {
password, password,
authenticationUid: id, authenticationUid: id,
passwordResetToken: null,
passwordResetTokenExpiresAt: null,
updatedById: currentUser.id, updatedById: currentUser.id,
}, },
{ transaction }, { transaction },

View File

@ -9,6 +9,11 @@ const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router(); const router = express.Router();
function getRequestOrigin(req) {
const referer = req.headers.referer || `${req.protocol}://${req.get('host')}${req.originalUrl}`;
return new URL(referer).origin;
}
/** /**
* @swagger * @swagger
* components: * components:
@ -104,14 +109,13 @@ router.post('/send-email-address-verification-email', passport.authenticate('jwt
throw new ForbiddenError(); throw new ForbiddenError();
} }
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email, getRequestOrigin(req));
const payload = true; const payload = true;
res.status(200).send(payload); res.status(200).send(payload);
})); }));
router.post('/send-password-reset-email', wrapAsync(async (req, res) => { router.post('/send-password-reset-email', wrapAsync(async (req, res) => {
const link = new URL(req.headers.referer); await AuthService.sendPasswordResetEmail(req.body.email, 'register', getRequestOrigin(req));
await AuthService.sendPasswordResetEmail(req.body.email, 'register', link.host,);
const payload = true; const payload = true;
res.status(200).send(payload); res.status(200).send(payload);
})); }));
@ -140,7 +144,6 @@ router.post('/send-password-reset-email', wrapAsync(async (req, res) => {
*/ */
router.post('/signup', wrapAsync(async (req, res) => { router.post('/signup', wrapAsync(async (req, res) => {
const link = new URL(req.headers.referer);
const payload = await AuthService.signup( const payload = await AuthService.signup(
req.body.email, req.body.email,
req.body.password, req.body.password,
@ -148,7 +151,7 @@ router.post('/signup', wrapAsync(async (req, res) => {
req.body.organizationId, req.body.organizationId,
req, req,
link.host, getRequestOrigin(req),
) )
res.status(200).send(payload); res.status(200).send(payload);
})); }));

View File

@ -10,6 +10,11 @@ const config = require('../config');
const router = express.Router(); const router = express.Router();
function getRequestOrigin(req) {
const referer = req.headers.referer || `${req.protocol}://${req.get('host')}${req.originalUrl}`;
return new URL(referer).origin;
}
const { parse } = require('json2csv'); const { parse } = require('json2csv');
@ -86,9 +91,7 @@ router.use(checkCrudPermissions('users'));
* description: Some server error * description: Some server error
*/ */
router.post('/', wrapAsync(async (req, res) => { router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; await UsersService.create(req.body.data, req.currentUser, true, getRequestOrigin(req));
const link = new URL(referer);
await UsersService.create(req.body.data, req.currentUser, true, link.host);
const payload = true; const payload = true;
res.status(200).send(payload); res.status(200).send(payload);
})); }));
@ -129,9 +132,7 @@ router.post('/', wrapAsync(async (req, res) => {
* *
*/ */
router.post('/bulk-import', wrapAsync(async (req, res) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; await UsersService.bulkImport(req, res, true, getRequestOrigin(req));
const link = new URL(referer);
await UsersService.bulkImport(req, res, true, link.host);
const payload = true; const payload = true;
res.status(200).send(payload); res.status(200).send(payload);
})); }));

View File

@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /api/
Disallow: /api-docs
Disallow: /login
Disallow: /register
Disallow: /forgot
Disallow: /password-reset
Disallow: /verify-email

View File

@ -0,0 +1,17 @@
{
"name": "Legacy Business Builder",
"short_name": "Legacy Builder",
"description": "AI-powered workflow to plan, fund, build, comply, staff, market, and launch a business from an idea.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

View File

@ -1,5 +1,5 @@
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'
@ -7,7 +7,8 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';
import { appTitle } from '../config';
import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation';
type Props = { type Props = {
menu: MenuAsideItem[] menu: MenuAsideItem[]
@ -31,6 +32,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const organizationsId = currentUser?.organizations?.id; const organizationsId = currentUser?.organizations?.id;
const [organizations, setOrganizations] = React.useState(null); const [organizations, setOrganizations] = React.useState(null);
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser);
const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => { const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => {
try { try {
@ -52,6 +54,9 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
organizationName = organizationName?.substring(0, 25) + '...'; organizationName = organizationName?.substring(0, 25) + '...';
} }
const brandSubLabel = organizationName
? `${organizationName} · Open ${primaryWorkspace.label}`
: `Workspace home · Open ${primaryWorkspace.label}`;
return ( return (
<aside <aside
@ -64,13 +69,18 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div <div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`} className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
> >
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <div className="flex-1 px-4 text-center lg:text-left xl:text-center">
<Link
<b className="font-black">Legacy Business Builder</b> href={primaryWorkspace.href}
className="group inline-flex max-w-full flex-col rounded-xl px-2 py-1 transition-colors hover:text-blue-600 dark:hover:text-sky-300"
>
{organizationName && <p>{organizationName}</p>} <span className="truncate text-sm font-black text-slate-900 transition-colors group-hover:text-blue-600 dark:text-white dark:group-hover:text-sky-300">
{appTitle}
</span>
<span className="truncate text-[11px] font-medium text-slate-500 dark:text-slate-400">
{brandSubLabel}
</span>
</Link>
</div> </div>
<button <button
className="hidden lg:inline-block xl:hidden p-3" className="hidden lg:inline-block xl:hidden p-3"

View File

@ -1,5 +1,6 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { containerMaxW } from '../config' import Link from 'next/link'
import { appTitle, containerMaxW } from '../config'
import Logo from './Logo' import Logo from './Logo'
type Props = { type Props = {
@ -10,25 +11,29 @@ 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={`border-t border-slate-200/80 px-6 py-4 dark:border-dark-700 ${containerMaxW}`}>
<div className="block md:flex items-center justify-between"> <div className="block md:flex items-center justify-between">
<div className="text-center md:text-left mb-6 md:mb-0"> <div className="mb-4 text-center md:mb-0 md:text-left">
<b> <div className="text-sm font-semibold text-slate-900 dark:text-white">&copy; {year} {appTitle}.</div>
&copy;{year},{` `} <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank"> {children || 'Protected founder workspace for planning, operations, and launch readiness.'}
Flatlogic </div>
</a>
.
</b>
{` `}
{children}
</div> </div>
<div className="flex item-center md:py-2 gap-4"> <div className="flex flex-wrap items-center justify-center gap-4 md:justify-end">
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank"> <Link href="/" className="text-sm text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
<Logo className="w-auto h-8 md:h-6 mx-auto" /> Home
</a> </Link>
</div> <Link href="/privacy-policy" className="text-sm text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
Privacy
</Link>
<Link href="/terms-of-use" className="text-sm text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
Terms
</Link>
<Link href="/" aria-label={`${appTitle} homepage`}>
<Logo className="h-9 w-9" />
</Link>
</div>
</div> </div>
</footer> </footer>
) )

View File

@ -0,0 +1,63 @@
import React from 'react';
import BaseButton from './BaseButton';
import { getPrimaryWorkspaceMeta, type PermissionAwareUser } from '../helpers/appNavigation';
import { useAppSelector } from '../stores/hooks';
type Props = {
title: string;
};
export default function GuestPageReturnBar({ title }: Props) {
const { currentUser } = useAppSelector((state) => state.auth);
const [storedUser, setStoredUser] = React.useState<PermissionAwareUser>(null);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const rawUser = localStorage.getItem('user');
if (!rawUser) {
return;
}
try {
setStoredUser(JSON.parse(rawUser));
} catch (error) {
console.error('Failed to parse stored user for guest page return bar:', error);
}
}, []);
const navigationUser = currentUser || storedUser;
const primaryWorkspace = getPrimaryWorkspaceMeta(navigationUser);
const hasActiveSession = Boolean(navigationUser);
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 pt-6 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#4F46E5]">
Quick return
</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-slate-950">
{title}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{hasActiveSession
? 'You are already signed in. Review this page, then jump straight back into your protected workspace.'
: 'Review this page, then head back to the homepage or login when you are ready to continue.'}
</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<BaseButton href="/" color="whiteDark" outline label="Homepage" />
<BaseButton
href={hasActiveSession ? primaryWorkspace.href : '/login'}
color="info"
icon={hasActiveSession ? primaryWorkspace.icon : undefined}
label={hasActiveSession ? `Return to ${primaryWorkspace.label}` : 'Login'}
/>
</div>
</div>
);
}

View File

@ -1,15 +1,25 @@
import React from 'react' import React from 'react'
import { appTitle } from '../../config'
type Props = { type Props = {
className?: string className?: string
} }
export default function Logo({ className = '' }: Props) { export default function Logo({ className = '' }: Props) {
const initials = appTitle
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join('')
.toUpperCase()
return ( return (
<img <span
src={"https://flatlogic.com/logo.svg"} aria-hidden="true"
className={className} className={`inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-[#EEF2FF] text-sm font-black tracking-[0.16em] text-[#4F46E5] shadow-sm shadow-indigo-200/60 ${className}`}
alt={'Flatlogic logo'}> >
</img> {initials}
</span>
) )
} }

View File

@ -1,113 +1,188 @@
import React from 'react'; import React from 'react';
import {toast, ToastContainer} from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head'; import Head from 'next/head';
import CardBox from '../components/CardBox'; import { Field, Form, Formik } from 'formik';
import SectionFullScreen from '../components/SectionFullScreen'; import { useRouter } from 'next/router';
import {useRouter} from 'next/router';
import {getPageTitle} from '../config';
import {Field, Form, Formik} from 'formik';
import FormField from '../components/FormField';
import BaseButtons from '../components/BaseButtons';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionFullScreen from '../components/SectionFullScreen';
import { getPageTitle } from '../config';
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
import { passwordReset } from '../stores/authSlice'; import { passwordReset } from '../stores/authSlice';
import {useAppDispatch} from '../stores/hooks'; import { useAppDispatch } from '../stores/hooks';
type PasswordResetValues = {
password: string;
confirm: string;
};
export default function PasswordSetOrReset() { export default function PasswordSetOrReset() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [isInvitation, setIsInvitation] = React.useState(false); const [formError, setFormError] = React.useState('');
const router = useRouter(); const router = useRouter();
const {token, invitation} = router.query; const dispatch = useAppDispatch();
const notify = (type, msg) => toast(msg, {type}); const tokenQuery = router.query.token;
const invitationQuery = router.query.invitation;
const token = typeof tokenQuery === 'string' ? tokenQuery : Array.isArray(tokenQuery) ? tokenQuery[0] ?? '' : '';
const invitation =
typeof invitationQuery === 'string'
? invitationQuery
: Array.isArray(invitationQuery)
? invitationQuery[0] ?? ''
: '';
const dispatch = useAppDispatch(); const hasToken = Boolean(token);
const isInvitation = Boolean(invitation) && invitation !== 'false';
React.useEffect(() => { const title = isInvitation ? 'Set Password' : 'Reset Password';
if (invitation) { const introText = isInvitation
setIsInvitation(true); ? 'Create a password to activate this account.'
} : 'Enter a new password for your account.';
}, [invitation]); const missingLinkMessage = isInvitation
? 'This invitation link is missing or incomplete. Please ask for a new invitation email.'
: 'This password reset link is missing or incomplete. Please request a new reset email.';
const invalidLinkMessage = isInvitation
? 'This invitation link is invalid or has expired. Please ask for a new invitation email.'
: 'This password reset link is invalid or has expired. Please request a new reset email.';
const handleSubmit = async (value) => { const { currentUser, isHydratingStoredSession } = useGuestAuthRedirect({
setLoading(true); enabled: router.isReady && !hasToken,
if (typeof token === 'string') { });
await dispatch(
passwordReset({
token,
password: value.password,
type: isInvitation && 'invitation',
}),
);
await router.push('/login');
}
setLoading(false); const isRedirectingSignedInUser = router.isReady && !hasToken && Boolean(currentUser?.id);
}; const shouldShowMissingLinkState =
router.isReady && !hasToken && !isHydratingStoredSession && !currentUser?.id;
return ( const handleSubmit = async (values: PasswordResetValues) => {
<> if (!hasToken) {
<Head> setFormError(missingLinkMessage);
{isInvitation && <title>{getPageTitle('Set Password')}</title>} return;
{!isInvitation && <title>{getPageTitle('Reset Password')}</title>} }
</Head>
<SectionFullScreen bg='violet'> if (!values.password) {
<div className='w-full flex flex-col items-center justify-center'> setFormError('Please enter a new password.');
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> return;
{isInvitation && <p className='text-xl mb-2'>Set Password</p>} }
{!isInvitation && <p className='text-xl mb-2'>Reset Password</p>}
<p className='text-base mb-4'>Enter your new password</p>
<Formik if (values.password !== values.confirm) {
initialValues={{ setFormError('Your passwords do not match. Please re-enter them.');
password: '', return;
confirm: '', }
}}
onSubmit={(values) => handleSubmit(values)}
>
{({errors, touched}) => (
<Form>
<FormField
>
<Field
type='password'
name='password'
placeholder='Password'
/>
</FormField>
<FormField
>
<Field
type='password'
name='confirm'
placeholder='Confirm Password'
/>
</FormField>
<BaseButtons> setLoading(true);
<BaseButton setFormError('');
className='w-full mt-3'
type='submit' try {
disabled={loading} await dispatch(
label={ passwordReset({
loading token,
? 'Loading...' password: values.password,
: isInvitation type: isInvitation ? 'invitation' : undefined,
? 'Set Password' }),
: 'Reset Password' ).unwrap();
}
color='info' await router.push('/login');
/> } catch (error) {
</BaseButtons> console.error('Password reset failed:', error);
</Form> const nextError = typeof error === 'string' && error ? error : invalidLinkMessage;
)} setFormError(nextError);
</Formik> toast.error(nextError);
</CardBox> } finally {
setLoading(false);
}
};
return (
<>
<Head>
<title>{getPageTitle(title)}</title>
</Head>
<SectionFullScreen bg='violet'>
<div className='flex w-full flex-col items-center justify-center'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<p className='mb-2 text-xl'>{title}</p>
<p className='mb-4 text-base'>{introText}</p>
{isHydratingStoredSession || isRedirectingSignedInUser ? (
<div className='mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-6 text-emerald-700'>
Checking your saved session and returning you to your workspace...
</div>
) : null}
{!router.isReady ? (
<p className='text-sm text-gray-500'>Loading...</p>
) : shouldShowMissingLinkState ? (
<>
<div className='mb-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800'>
{missingLinkMessage}
</div> </div>
</SectionFullScreen> <BaseButtons mb='mb-0'>
<ToastContainer/> {isInvitation ? (
</> <>
); <BaseButton href='/login' label='Login' color='info' />
<BaseButton href='/' label='Homepage' color='whiteDark' outline />
</>
) : (
<>
<BaseButton href='/forgot' label='Request new link' color='info' />
<BaseButton href='/login' label='Login' color='whiteDark' outline />
</>
)}
</BaseButtons>
</>
) : (
<Formik<PasswordResetValues>
initialValues={{
password: '',
confirm: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
{formError ? (
<div className='mb-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm leading-6 text-red-700'>
{formError}
</div>
) : null}
<FormField label='New password' help='Choose the password you want to use from now on.'>
<Field type='password' name='password' placeholder='New password' />
</FormField>
<FormField
label='Confirm password'
help='Re-enter the same password so we can confirm it before saving.'
>
<Field type='password' name='confirm' placeholder='Confirm password' />
</FormField>
<BaseButtons>
<BaseButton
className='mt-3 w-full'
type='submit'
disabled={loading || isHydratingStoredSession}
label={loading || isHydratingStoredSession ? 'Loading...' : title}
color='info'
/>
</BaseButtons>
<BaseButtons type='justify-center' mb='mb-0' className='mt-2'>
{isInvitation ? (
<BaseButton href='/login' label='Back to login' color='whiteDark' outline />
) : (
<BaseButton href='/forgot' label='Need a new link?' color='whiteDark' outline />
)}
</BaseButtons>
</Form>
</Formik>
)}
</CardBox>
</div>
</SectionFullScreen>
<ToastContainer />
</>
);
} }

View File

@ -31,12 +31,12 @@ const Search = () => {
validateOnChange={false} validateOnChange={false}
> >
{({ errors, touched, values }) => ( {({ errors, touched, values }) => (
<Form style={{width: '300px'}} > <Form className='w-40 sm:w-52 md:w-56 lg:w-60 xl:w-72'>
<Field <Field
id='search' id='search'
name='search' name='search'
validate={validateSearch} validate={validateSearch}
placeholder='Search' placeholder='Search workspace'
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={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
/> />
{errors.search && touched.search && values.search.length < 2 ? ( {errors.search && touched.search && values.search.length < 2 ? (

View File

@ -1,3 +1,5 @@
import { humanize } from './helpers/humanize';
export const hostApi = process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API ? 'http://localhost' : '' export const hostApi = process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API ? 'http://localhost' : ''
export const portApi = process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API ? 8080 : ''; export const portApi = process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API ? 8080 : '';
export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api` export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`
@ -8,8 +10,58 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
export const appTitle = 'created by Flatlogic generator!' export const appTitle = 'Legacy Business Builder'
export const appDescription = 'AI-powered workflow to plan, fund, build, comply, staff, market, and launch a business from an idea.'
export const publicSiteUrl = process.env.NEXT_PUBLIC_SITE_URL || ''
export const publicSupportEmail = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || ''
export const showDevelopmentCredentials = process.env.NODE_ENV !== 'production'
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}` const lowercaseWords = new Set(['a', 'an', 'and', 'as', 'at', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'with'])
const formatPageTitle = (currentPageTitle: string) => {
if (!currentPageTitle) {
return ''
}
if (currentPageTitle === 'New Item') {
return 'Create Record'
}
return humanize(currentPageTitle)
.split(' ')
.filter(Boolean)
.map((word, index) => {
const normalized = word.toLowerCase()
if (normalized === 'ai') {
return 'AI'
}
if (normalized === 'api') {
return 'API'
}
if (normalized === 'faq') {
return 'FAQ'
}
if (index > 0 && lowercaseWords.has(normalized)) {
return normalized
}
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
})
.join(' ')
}
export const getPageTitle = (currentPageTitle: string) => {
const formattedTitle = formatPageTitle(currentPageTitle)
if (!formattedTitle || formattedTitle === appTitle) {
return appTitle
}
return `${formattedTitle}${appTitle}`
}
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '' export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''

View File

@ -1,7 +1,6 @@
@import "tailwind/_base.css"; @import "tailwind/_base.css";
@import "tailwind/_components.css"; @import "tailwind/_components.css";
@import "tailwind/_utilities.css"; @import "tailwind/_utilities.css";
@import 'intro.js/introjs.css';
@import "_checkbox-radio-switch.css"; @import "_checkbox-radio-switch.css";
@import "_progress.css"; @import "_progress.css";
@import "_scrollbars.css"; @import "_scrollbars.css";
@ -11,25 +10,3 @@
@import '_select-dropdown.css'; @import '_select-dropdown.css';
@import "_theme.css"; @import "_theme.css";
@import '_rich-text.css'; @import '_rich-text.css';
.introjs-tooltip {
@apply min-w-[400px] max-w-[480px] p-2 !important;
}
.good-img {
@apply -mt-96 !important;
}
.end-img {
@apply -mt-72 !important;
}
.introjs-button {
@apply bg-blue-600 text-white !important;
text-shadow: none !important;
}
.introjs-bullets ul li a.active {
@apply bg-blue-600 !important;
}
.introjs-prevbutton{
@apply bg-transparent border border-blue-600 text-blue-600 !important;
}

View File

@ -0,0 +1,49 @@
import { mdiChartTimelineVariant, mdiRobotOutline, mdiViewDashboardOutline } from '@mdi/js';
import { hasPermission } from './userPermissions';
export type PermissionAwareUser = {
app_role?: {
name?: string;
globalAccess?: boolean;
} | null;
custom_permissions?: Array<{ name: string }>;
app_role_permissions?: Array<{ name: string }>;
} | null | undefined;
export type PrimaryWorkspaceMeta = {
href: string;
label: string;
icon: string;
};
export function getPrimaryWorkspaceMeta(user: PermissionAwareUser): PrimaryWorkspaceMeta {
if (hasPermission(user, 'READ_PROJECTS')) {
return {
href: '/business-command-center',
label: 'Command center',
icon: mdiViewDashboardOutline,
};
}
if (hasPermission(user, 'CREATE_PROJECTS')) {
return {
href: '/legacy-launchpad',
label: 'Legacy Launchpad',
icon: mdiRobotOutline,
};
}
return {
href: '/dashboard',
label: 'Admin overview',
icon: mdiChartTimelineVariant,
};
}
export function getPrimaryWorkspaceRoute(user: PermissionAwareUser) {
return getPrimaryWorkspaceMeta(user).href;
}
export function getPrimaryWorkspaceLabel(user: PermissionAwareUser) {
return getPrimaryWorkspaceMeta(user).label;
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { useRouter } from 'next/router';
import { getPrimaryWorkspaceRoute } from '../helpers/appNavigation';
import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
type Options = {
enabled?: boolean;
};
export default function useGuestAuthRedirect({ enabled = true }: Options = {}) {
const dispatch = useAppDispatch();
const router = useRouter();
const { currentUser, token } = useAppSelector((state) => state.auth);
const [isHydratingStoredSession, setIsHydratingStoredSession] = React.useState(false);
React.useEffect(() => {
if (!enabled) {
setIsHydratingStoredSession(false);
return;
}
if (typeof window === 'undefined') {
return;
}
if (currentUser?.id) {
setIsHydratingStoredSession(false);
return;
}
const storedToken = token || localStorage.getItem('token');
if (!storedToken) {
setIsHydratingStoredSession(false);
return;
}
let isMounted = true;
const hydrateCurrentUser = async () => {
setIsHydratingStoredSession(true);
try {
await dispatch(findMe()).unwrap();
} catch (error) {
console.error('Failed to hydrate stored session on guest auth page:', error);
} finally {
if (isMounted) {
setIsHydratingStoredSession(false);
}
}
};
hydrateCurrentUser();
return () => {
isMounted = false;
};
}, [currentUser?.id, dispatch, enabled, token]);
React.useEffect(() => {
if (!enabled || !currentUser?.id) {
return;
}
const nextRoute = getPrimaryWorkspaceRoute(currentUser);
router.replace(nextRoute);
}, [currentUser, currentUser?.id, enabled, router]);
return {
currentUser,
isHydratingStoredSession,
};
}

View File

@ -8,12 +8,14 @@ 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 BaseButton from '../components/BaseButton'
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";
import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation'
type Props = { type Props = {
@ -65,6 +67,7 @@ export default function LayoutAuthenticated({
const darkMode = useAppSelector((state) => state.style.darkMode) const darkMode = useAppSelector((state) => state.style.darkMode)
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false) const [isAsideLgActive, setIsAsideLgActive] = useState(false)
@ -95,7 +98,7 @@ export default function LayoutAuthenticated({
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`} } pt-14 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(currentUser)}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`} className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
> >
<NavBarItemPlain <NavBarItemPlain
@ -110,6 +113,24 @@ export default function LayoutAuthenticated({
> >
<BaseIcon path={mdiMenu} size="24" /> <BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain> </NavBarItemPlain>
<NavBarItemPlain display="hidden lg:flex xl:hidden" useMargin>
<BaseButton
href={primaryWorkspace.href}
color="info"
small
icon={primaryWorkspace.icon}
label="Workspace"
/>
</NavBarItemPlain>
<NavBarItemPlain display="hidden xl:flex" useMargin>
<BaseButton
href={primaryWorkspace.href}
color="info"
small
icon={primaryWorkspace.icon}
label={`Open ${primaryWorkspace.label}`}
/>
</NavBarItemPlain>
<NavBarItemPlain useMargin> <NavBarItemPlain useMargin>
<Search /> <Search />
</NavBarItemPlain> </NavBarItemPlain>
@ -121,7 +142,7 @@ export default function LayoutAuthenticated({
onAsideLgClose={() => setIsAsideLgActive(false)} onAsideLgClose={() => setIsAsideLgActive(false)}
/> />
{children} {children}
<FooterBar>Hand-crafted & Made with </FooterBar> <FooterBar>Private founder workspace for planning, operations, and launch readiness.</FooterBar>
</div> </div>
</div> </div>
) )

View File

@ -3,9 +3,10 @@ import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [ const menuAside: MenuAsideItem[] = [
{ {
href: '/dashboard', href: '/business-command-center',
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Command center',
permissions: 'READ_PROJECTS',
}, },
{ {
href: '/legacy-launchpad', href: '/legacy-launchpad',
@ -14,12 +15,26 @@ const menuAside: MenuAsideItem[] = [
permissions: 'CREATE_PROJECTS', permissions: 'CREATE_PROJECTS',
}, },
{ {
href: '/business-command-center', href: '/projects/projects-list',
icon: icon.mdiViewDashboardOutline, label: 'Project library',
label: 'Command center', // eslint-disable-next-line @typescript-eslint/ban-ts-comment
permissions: 'READ_PROJECTS', // @ts-ignore
icon: 'mdiBriefcaseOutline' in icon ? icon['mdiBriefcaseOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PROJECTS'
},
{
href: '/business_ideas/business_ideas-list',
label: 'Business ideas',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLightbulbOnOutline' in icon ? icon['mdiLightbulbOnOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESS_IDEAS'
},
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Admin overview',
}, },
{ {
href: '/users/users-list', href: '/users/users-list',
label: 'Users', label: 'Users',
@ -52,14 +67,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiTable ?? icon.mdiTable, icon: icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORGANIZATIONS' permissions: 'READ_ORGANIZATIONS'
}, },
{
href: '/business_ideas/business_ideas-list',
label: 'Business ideas',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLightbulbOnOutline' in icon ? icon['mdiLightbulbOnOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESS_IDEAS'
},
{ {
href: '/locations/locations-list', href: '/locations/locations-list',
label: 'Locations', label: 'Locations',
@ -68,14 +75,6 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LOCATIONS' permissions: 'READ_LOCATIONS'
}, },
{
href: '/projects/projects-list',
label: 'Projects',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBriefcaseOutline' in icon ? icon['mdiBriefcaseOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PROJECTS'
},
{ {
href: '/project_phases/project_phases-list', href: '/project_phases/project_phases-list',
label: 'Project phases', label: 'Project phases',
@ -166,7 +165,7 @@ const menuAside: MenuAsideItem[] = [
}, },
{ {
href: '/ai_runs/ai_runs-list', href: '/ai_runs/ai_runs-list',
label: 'Ai runs', label: 'AI runs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
icon: 'mdiRobotOutline' in icon ? icon['mdiRobotOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon: 'mdiRobotOutline' in icon ? icon['mdiRobotOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -177,8 +176,6 @@ const menuAside: MenuAsideItem[] = [
label: 'Profile', label: 'Profile',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountCircle,
}, },
{ {
href: '/api-docs', href: '/api-docs',
target: '_blank', target: '_blank',

View File

@ -1,53 +1,53 @@
import { import {
mdiMenu,
mdiClockOutline,
mdiCloud,
mdiCrop,
mdiAccount, mdiAccount,
mdiCogOutline,
mdiEmail,
mdiLogout, mdiLogout,
mdiThemeLightDark, mdiThemeLightDark,
mdiGithub,
mdiVuejs,
} from '@mdi/js' } from '@mdi/js'
import { MenuNavBarItem } from './interfaces' import { MenuNavBarItem } from './interfaces'
import { getPrimaryWorkspaceMeta, type PermissionAwareUser } from './helpers/appNavigation'
const menuNavBar: MenuNavBarItem[] = [ const menuNavBar = (currentUser?: PermissionAwareUser): MenuNavBarItem[] => {
{ const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser)
isCurrentUser: true,
menu: [
{
icon: mdiAccount,
label: 'My Profile',
href: '/profile',
},
{
isDivider: true,
},
{
icon: mdiLogout,
label: 'Log Out',
isLogout: true,
},
],
},
{
icon: mdiThemeLightDark,
label: 'Light/Dark',
isDesktopNoLabel: true,
isToggleLightDark: true,
},
{
icon: mdiLogout,
label: 'Log out',
isDesktopNoLabel: true,
isLogout: true,
},
]
export const webPagesNavBar = [ return [
{
]; isCurrentUser: true,
menu: [
{
icon: primaryWorkspace.icon,
label: `Open ${primaryWorkspace.label}`,
href: primaryWorkspace.href,
},
{
icon: mdiAccount,
label: 'My Profile',
href: '/profile',
},
{
isDivider: true,
},
{
icon: mdiLogout,
label: 'Log Out',
isLogout: true,
},
],
},
{
icon: mdiThemeLightDark,
label: 'Light/Dark',
isDesktopNoLabel: true,
isToggleLightDark: true,
},
{
icon: mdiLogout,
label: 'Log out',
isDesktopNoLabel: true,
isLogout: true,
},
]
}
export const webPagesNavBar = []
export default menuNavBar export default menuNavBar

View File

@ -0,0 +1,33 @@
import React from 'react'
import type { ReactElement } from 'react'
import Head from 'next/head'
import CardBox from '../components/CardBox'
import GuestPageReturnBar from '../components/GuestPageReturnBar'
import { getPageTitle } from '../config'
import LayoutGuest from '../layouts/Guest'
export default function NotFoundPage() {
return (
<div className="min-h-screen bg-slate-50">
<Head>
<title>{getPageTitle('Page not found')}</title>
</Head>
<GuestPageReturnBar title="Page not found" />
<div className="mx-auto w-full max-w-4xl px-4 pb-12 pt-6 sm:px-6 lg:px-8">
<CardBox className="border border-slate-200 shadow-sm shadow-slate-200/60">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-[#4F46E5]">404</div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">That page doesnt exist.</h1>
<p className="mt-4 text-sm leading-7 text-slate-600">
The link may be outdated, incomplete, or already moved. Use the quick return options above to get back to a stable page.
</p>
</CardBox>
</div>
</div>
)
}
NotFoundPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>
}

View File

@ -0,0 +1,33 @@
import React from 'react'
import type { ReactElement } from 'react'
import Head from 'next/head'
import CardBox from '../components/CardBox'
import GuestPageReturnBar from '../components/GuestPageReturnBar'
import { getPageTitle } from '../config'
import LayoutGuest from '../layouts/Guest'
export default function ServerErrorPage() {
return (
<div className="min-h-screen bg-slate-50">
<Head>
<title>{getPageTitle('Server error')}</title>
</Head>
<GuestPageReturnBar title="Something went wrong" />
<div className="mx-auto w-full max-w-4xl px-4 pb-12 pt-6 sm:px-6 lg:px-8">
<CardBox className="border border-amber-200 bg-amber-50/40 shadow-sm shadow-amber-100/70">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-amber-700">500</div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">We hit an unexpected problem loading this page.</h1>
<p className="mt-4 text-sm leading-7 text-slate-700">
Please return to the homepage or your workspace and try again from a stable screen. If the issue keeps happening, check your support contact settings before launch.
</p>
</CardBox>
</div>
</div>
)
}
ServerErrorPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>
}

View File

@ -3,199 +3,161 @@ import type { AppProps } from 'next/app';
import type { ReactElement, ReactNode } from 'react'; import type { ReactElement, ReactNode } from 'react';
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import { store } from '../stores/store';
import { Provider } from 'react-redux';
import '../css/main.css';
import axios from 'axios';
import { baseURLApi } from '../config';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import ErrorBoundary from "../components/ErrorBoundary"; import { Provider } from 'react-redux';
import DevModeBadge from '../components/DevModeBadge'; import axios from 'axios';
import 'intro.js/introjs.css';
import { appWithTranslation } from 'next-i18next'; import { appWithTranslation } from 'next-i18next';
import ErrorBoundary from '../components/ErrorBoundary';
import DevModeBadge from '../components/DevModeBadge';
import { appDescription, appTitle, baseURLApi, publicSiteUrl } from '../config';
import '../css/main.css';
import '../i18n'; import '../i18n';
import IntroGuide from '../components/IntroGuide'; import { store } from '../stores/store';
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
// Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
? process.env.NEXT_PUBLIC_BACK_API ? process.env.NEXT_PUBLIC_BACK_API
: baseURLApi; : baseURLApi;
axios.defaults.headers.common['Content-Type'] = 'application/json'; axios.defaults.headers.common['Content-Type'] = 'application/json';
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & { export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode getLayout?: (page: ReactElement) => ReactNode;
} };
type AppPropsWithLayout = AppProps & { type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout Component: NextPageWithLayout;
} };
function MyApp({ Component, pageProps }: AppPropsWithLayout) { function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page); const getLayout = Component.getLayout || ((page) => page);
const router = useRouter(); const router = useRouter();
const [stepsEnabled, setStepsEnabled] = React.useState(false);
const [stepName, setStepName] = React.useState('');
const [steps, setSteps] = React.useState([]);
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
delete config.headers.Authorization;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// TODO: Remove this code in future releases
React.useEffect(() => {
const allowedOrigin = (() => {
if (!document.referrer) {
return null;
}
try {
return new URL(document.referrer).origin;
} catch (error) {
console.warn('[postMessage] Failed to parse parent origin from referrer', error);
return null;
}
})();
const handleMessage = async (event: MessageEvent) => {
if (event.data === 'getLocation') {
event.source?.postMessage(
{ iframeLocation: window.location.pathname },
event.origin,
);
return;
}
if (event.data === 'getAuthToken') {
if (allowedOrigin && event.origin !== allowedOrigin) {
console.warn('[postMessage] Blocked getAuthToken from origin', event.origin);
return;
}
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
event.source?.postMessage(
{ iframeAuthToken: token, iframeAuthUser: user },
event.origin,
);
return;
}
if (event.data === 'getScreenshot') {
try {
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(document.body, { useCORS: true });
const url = canvas.toDataURL('image/jpeg', 0.8);
event.source?.postMessage({ iframeScreenshot: url }, event.origin);
} catch (e) {
console.error('html2canvas failed', e);
event.source?.postMessage({ iframeScreenshot: null }, event.origin);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
React.useEffect(() => { React.useEffect(() => {
// Tour is disabled by default in generated projects. const interceptorId = axios.interceptors.request.use(
return; (config) => {
const isCompleted = (stepKey: string) => { if (typeof window === 'undefined') {
return localStorage.getItem(`completed_${stepKey}`) === 'true'; return config;
}
const token = window.localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
delete config.headers.Authorization;
}
return config;
},
(error) => Promise.reject(error),
);
return () => {
axios.interceptors.request.eject(interceptorId);
}; };
if (router.pathname === '/login' && !isCompleted('loginSteps')) { }, []);
setSteps(loginSteps);
setStepName('loginSteps'); React.useEffect(() => {
setStepsEnabled(true); const allowedOrigin = (() => {
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) { if (!document.referrer) {
setTimeout(() => { return null;
setSteps(appSteps); }
setStepName('appSteps');
setStepsEnabled(true); try {
}, 1000); return new URL(document.referrer).origin;
} else if (router.pathname === '/users/users-list' && !isCompleted('usersSteps')) { } catch (error) {
setTimeout(() => { console.warn('[postMessage] Failed to parse parent origin from referrer', error);
setSteps(usersSteps); return null;
setStepName('usersSteps'); }
setStepsEnabled(true); })();
}, 1000);
} else if (router.pathname === '/roles/roles-list' && !isCompleted('rolesSteps')) { const handleMessage = async (event: MessageEvent) => {
setTimeout(() => { if (event.data === 'getLocation') {
setSteps(rolesSteps); event.source?.postMessage({ iframeLocation: window.location.pathname }, event.origin);
setStepName('rolesSteps'); return;
setStepsEnabled(true); }
}, 1000);
} else { if (event.data === 'getAuthToken') {
setSteps([]); if (allowedOrigin && event.origin !== allowedOrigin) {
setStepsEnabled(false); console.warn('[postMessage] Blocked getAuthToken from origin', event.origin);
return;
}
const token = window.localStorage.getItem('token');
const user = window.localStorage.getItem('user');
event.source?.postMessage({ iframeAuthToken: token, iframeAuthUser: user }, event.origin);
return;
}
if (event.data === 'getScreenshot') {
try {
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(document.body, { useCORS: true });
const url = canvas.toDataURL('image/jpeg', 0.8);
event.source?.postMessage({ iframeScreenshot: url }, event.origin);
} catch (error) {
console.error('html2canvas failed', error);
event.source?.postMessage({ iframeScreenshot: null }, event.origin);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
const canonicalUrl = React.useMemo(() => {
if (!publicSiteUrl) {
return '';
} }
}, [router.pathname]);
const handleExit = () => { try {
setStepsEnabled(false); return new URL(router.asPath || '/', publicSiteUrl).toString();
}; } catch (error) {
return publicSiteUrl;
}
}, [router.asPath]);
const title = 'Legacy Business Builder' const image = 'https://project-screens.s3.amazonaws.com/screenshots/39850/app-hero-20260501-064335.png';
const description = "AI-powered workflow to plan, fund, build, comply, staff, market, and launch a business from an idea." const imageWidth = '1920';
const url = "https://flatlogic.com/" const imageHeight = '960';
const image = "https://project-screens.s3.amazonaws.com/screenshots/39850/app-hero-20260501-064335.png"
const imageWidth = '1920'
const imageHeight = '960'
return ( return (
<Provider store={store}> <Provider store={store}>
{getLayout( {getLayout(
<> <>
<Head> <Head>
<meta name="description" content={description} /> <meta name="application-name" content={appTitle} />
<meta name="description" content={appDescription} />
<meta property="og:url" content={url} /> <meta name="theme-color" content="#4f46e5" />
<meta property="og:site_name" content="https://flatlogic.com/" /> {canonicalUrl ? <link rel="canonical" href={canonicalUrl} /> : null}
<meta property="og:title" content={title} /> {canonicalUrl ? <meta property="og:url" content={canonicalUrl} /> : null}
<meta property="og:description" content={description} /> <meta property="og:site_name" content={appTitle} />
<meta property="og:title" content={appTitle} />
<meta property="og:description" content={appDescription} />
<meta property="og:image" content={image} /> <meta property="og:image" content={image} />
<meta property="og:image:type" content="image/png" /> <meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content={imageWidth} /> <meta property="og:image:width" content={imageWidth} />
<meta property="og:image:height" content={imageHeight} /> <meta property="og:image:height" content={imageHeight} />
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={title} /> <meta property="twitter:title" content={appTitle} />
<meta property="twitter:description" content={description} /> <meta property="twitter:description" content={appDescription} />
<meta property="twitter:image:src" content={image} /> <meta property="twitter:image:src" content={image} />
<meta property="twitter:image:width" content={imageWidth} /> <meta property="twitter:image:width" content={imageWidth} />
<meta property="twitter:image:height" content={imageHeight} /> <meta property="twitter:image:height" content={imageHeight} />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />
</Head> </Head>
<ErrorBoundary> <ErrorBoundary>
<Component {...pageProps} /> <Component {...pageProps} />
</ErrorBoundary> </ErrorBoundary>
<IntroGuide
steps={steps} {(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
stepsName={stepName}
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
</> </>
)} )}
</Provider> </Provider>
) );
} }
export default appWithTranslation(MyApp); export default appWithTranslation(MyApp);

View File

@ -286,12 +286,12 @@ function diffInCalendarDays(from: Date, to: Date) {
function formatDate(value: any) { function formatDate(value: any) {
if (!value) { if (!value) {
return 'TBD'; return 'Not scheduled yet';
} }
const parsed = new Date(value); const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) { if (Number.isNaN(parsed.getTime())) {
return 'TBD'; return 'Not scheduled yet';
} }
return dateFormatter.format(parsed); return dateFormatter.format(parsed);
@ -3746,7 +3746,11 @@ const BusinessCommandCenter = () => {
<NotificationBar <NotificationBar
button={ button={
<BaseButton color="warning" href="/legacy-launchpad" icon={mdiRobotOutline} label="Open Launchpad" /> canCreateProjects ? (
<BaseButton color="warning" href="/legacy-launchpad" icon={mdiRobotOutline} label="Open Launchpad" />
) : canReadProjects ? (
<BaseButton color="info" href="/projects/projects-list" icon={mdiOpenInNew} label="Project library" />
) : undefined
} }
color="warning" color="warning"
icon={mdiAlertCircle} icon={mdiAlertCircle}
@ -3908,14 +3912,104 @@ const BusinessCommandCenter = () => {
<MetricCard helper={`${formatCurrency(fundingCommitted)} committed and ${formatCurrency(fundingFunded)} funded so far.`} icon={mdiCashMultiple} label="Capital funded" value={formatCurrency(fundingFunded)} /> <MetricCard helper={`${formatCurrency(fundingCommitted)} committed and ${formatCurrency(fundingFunded)} funded so far.`} icon={mdiCashMultiple} label="Capital funded" value={formatCurrency(fundingFunded)} />
<MetricCard helper={`${approvedDocuments} approved documents, ${publishedTrainingPrograms} published trainings, ${runningCampaigns} live campaigns.`} icon={mdiFileDocumentOutline} label="Launch materials ready" value={approvedDocuments + publishedTrainingPrograms + runningCampaigns} /> <MetricCard helper={`${approvedDocuments} approved documents, ${publishedTrainingPrograms} published trainings, ${runningCampaigns} live campaigns.`} icon={mdiFileDocumentOutline} label="Launch materials ready" value={approvedDocuments + publishedTrainingPrograms + runningCampaigns} />
</div> </div>
<div className="mb-6"> <div className="mb-6">
<SectionCard <SectionCard
action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review saved stage" outline small /> : undefined} action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review workspace" outline small /> : projectViewHref ? <BaseButton color="info" href={projectViewHref} icon={mdiOpenInNew} label="Open project" outline small /> : undefined}
eyebrow="Automatic stage runner" eyebrow="Founder controls"
icon={mdiChartTimelineVariant} icon={mdiHomeCityOutline}
title="Data-driven stage check" title="Workspace control panel"
> >
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 text-sm leading-6 text-slate-700 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300">
Use this panel when you want to jump straight into the underlying systems behind the command center. It keeps the real records one click away so execution details stay easy to open while the page stays founder-friendly.
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiCashMultiple} size={18} />
<span className="font-semibold">Funding and runway</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{formatCurrency(fundingFunded)}</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{[leadFundingRound?.round_name || 'No funding round saved yet', fundingTarget ? `${formatCurrency(fundingTarget)} target` : null, fundingCommitted ? `${formatCurrency(fundingCommitted)} committed` : null]
.filter(Boolean)
.join(' • ')}
</p>
{canReadFunding ? <BaseButton className="mt-3" color="info" href="/funding_rounds/funding_rounds-list" icon={mdiArrowRight} label="Funding rounds" outline small /> : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiHomeCityOutline} size={18} />
<span className="font-semibold">Site and property</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{leadProperty ? humanize(leadProperty.acquisition_status || 'candidate') : 'No site saved'}</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{leadProperty
? [leadProperty.property_name || 'Unnamed property', leadProperty.property_type ? humanize(leadProperty.property_type) : null, leadProperty.purchase_price ? formatCurrency(leadProperty.purchase_price) : leadProperty.asking_price ? formatCurrency(leadProperty.asking_price) : null]
.filter(Boolean)
.join(' • ')
: 'Track candidate sites, purchase terms, diligence files, or leases here.'}
</p>
{canReadProperties ? <BaseButton className="mt-3" color="info" href="/properties/properties-list" icon={mdiArrowRight} label="Property records" outline small /> : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiScaleBalance} size={18} />
<span className="font-semibold">Compliance and documents</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{approvedLegalCount}/{legalRequirements.length || 0} approved</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{approvedDocuments} approved documents and {draftedDocuments} drafts are saved for the launch.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{canReadLegal ? <BaseButton color="info" href="/legal_requirements/legal_requirements-list" icon={mdiArrowRight} label="Compliance" outline small /> : null}
{canReadDocuments ? <BaseButton color="info" href="/documents/documents-list" icon={mdiArrowRight} label="Documents" outline small /> : null}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiAccountTieOutline} size={18} />
<span className="font-semibold">People and training</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{openRoles} open roles</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{publishedTrainingPrograms} published training programs are ready for onboarding.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{canReadPositions ? <BaseButton color="info" href="/positions/positions-list" icon={mdiArrowRight} label="Roles" outline small /> : null}
{canReadTraining ? <BaseButton color="info" href="/training_programs/training_programs-list" icon={mdiArrowRight} label="Training" outline small /> : null}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800 sm:col-span-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiBullhornOutline} size={18} />
<span className="font-semibold">Marketing, assets, and AI monitoring</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{runningCampaigns} live campaigns</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{plannedCampaigns} planned campaigns, {designAssets.length} design assets, and {aiRuns.length} AI runs are saved in this workspace.
</p>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{latestAiRun
? `Latest AI activity: ${humanize(latestAiRun.run_type || 'run')}${humanize(latestAiRun.status || 'queued')}${formatDate(latestAiRun.finished_at || latestAiRun.started_at || latestAiRun.createdAt)}`
: 'No AI activity has been logged yet for this project.'}
</p>
</div>
<div className="flex flex-wrap gap-2">
{canReadMarketing ? <BaseButton color="info" href="/marketing_campaigns/marketing_campaigns-list" icon={mdiArrowRight} label="Campaigns" outline small /> : null}
{canReadAiRuns ? <BaseButton color="info" href="/ai_runs/ai_runs-list" icon={mdiArrowRight} label="AI runs" outline small /> : null}
</div>
</div>
</div>
</div>
</SectionCard>
</div>
<div className="mb-6"> <div className="mb-6">
<SectionCard <SectionCard
action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review launch plan" outline small /> : projectViewHref ? <BaseButton color="info" href={projectViewHref} icon={mdiArrowRight} label="Open workspace" outline small /> : undefined} action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review launch plan" outline small /> : projectViewHref ? <BaseButton color="info" href={projectViewHref} icon={mdiArrowRight} label="Open workspace" outline small /> : undefined}
@ -4104,7 +4198,17 @@ const BusinessCommandCenter = () => {
</SectionCard> </SectionCard>
</div> </div>
<div className="grid gap-6 xl:grid-cols-12"> <div className="mb-6">
<SectionCard
action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review saved stage" outline small /> : projectViewHref ? <BaseButton color="info" href={projectViewHref} icon={mdiOpenInNew} label="Open workspace" outline small /> : undefined}
eyebrow="Automatic stage runner"
icon={mdiChartTimelineVariant}
title="Data-driven stage check"
>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 text-sm leading-6 text-slate-700 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300">
The stage runner compares the saved workspace against the launch stages below. Use it to see when the recorded stage is moving ahead of the proof, or when the plan is mature enough to push forward with more confidence.
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-12">
<div className="xl:col-span-4"> <div className="xl:col-span-4">
<div className={`rounded-3xl border p-5 ${getStageRunnerNoticeClasses(stageRunner.drift, stageRunner.currentStage.status)}`}> <div className={`rounded-3xl border p-5 ${getStageRunnerNoticeClasses(stageRunner.drift, stageRunner.currentStage.status)}`}>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-300">Working stage</div> <div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-300">Working stage</div>
@ -4191,7 +4295,7 @@ const BusinessCommandCenter = () => {
</div> </div>
<div className="mb-6 grid gap-6 xl:grid-cols-12"> <div className="mb-6 grid gap-6 xl:grid-cols-12">
<div className="xl:col-span-7"> <div className="xl:col-span-12">
<SectionCard <SectionCard
action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review dates" outline small /> : undefined} action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review dates" outline small /> : undefined}
eyebrow="Critical path" eyebrow="Critical path"
@ -5057,7 +5161,7 @@ const BusinessCommandCenter = () => {
</SectionCard> </SectionCard>
</div> </div>
<div className="xl:col-span-7"> <div className="xl:col-span-12">
<SectionCard <SectionCard
action={canReadProjectPhases ? <BaseButton color="info" href="/project_phases/project_phases-list" icon={mdiArrowRight} label="Open phases" outline small /> : undefined} action={canReadProjectPhases ? <BaseButton color="info" href="/project_phases/project_phases-list" icon={mdiArrowRight} label="Open phases" outline small /> : undefined}
eyebrow="Roadmap" eyebrow="Roadmap"
@ -5091,95 +5195,6 @@ const BusinessCommandCenter = () => {
</SectionCard> </SectionCard>
</div> </div>
<div className="xl:col-span-5">
<SectionCard eyebrow="Readiness lanes" icon={mdiHomeCityOutline} title="Launch readiness snapshot">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiCashMultiple} size={18} />
<span className="font-semibold">Funding and runway</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{formatCurrency(fundingFunded)}</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{[leadFundingRound?.round_name || 'No funding round saved yet', fundingTarget ? `${formatCurrency(fundingTarget)} target` : null, fundingCommitted ? `${formatCurrency(fundingCommitted)} committed` : null]
.filter(Boolean)
.join(' • ')}
</p>
{canReadFunding ? <BaseButton className="mt-3" color="info" href="/funding_rounds/funding_rounds-list" icon={mdiArrowRight} label="Funding rounds" outline small /> : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiHomeCityOutline} size={18} />
<span className="font-semibold">Site and property</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{leadProperty ? humanize(leadProperty.acquisition_status || 'candidate') : 'No site saved'}</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{leadProperty
? [leadProperty.property_name || 'Unnamed property', leadProperty.property_type ? humanize(leadProperty.property_type) : null, leadProperty.purchase_price ? formatCurrency(leadProperty.purchase_price) : leadProperty.asking_price ? formatCurrency(leadProperty.asking_price) : null]
.filter(Boolean)
.join(' • ')
: 'Track candidate sites, purchase terms, diligence files, or leases here.'}
</p>
{canReadProperties ? <BaseButton className="mt-3" color="info" href="/properties/properties-list" icon={mdiArrowRight} label="Property records" outline small /> : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiScaleBalance} size={18} />
<span className="font-semibold">Compliance and documents</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{approvedLegalCount}/{legalRequirements.length || 0} approved</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{approvedDocuments} approved documents and {draftedDocuments} drafts are saved for the launch.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{canReadLegal ? <BaseButton color="info" href="/legal_requirements/legal_requirements-list" icon={mdiArrowRight} label="Compliance" outline small /> : null}
{canReadDocuments ? <BaseButton color="info" href="/documents/documents-list" icon={mdiArrowRight} label="Documents" outline small /> : null}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiAccountTieOutline} size={18} />
<span className="font-semibold">People and training</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{openRoles} open roles</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{publishedTrainingPrograms} published training programs are ready for onboarding.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{canReadPositions ? <BaseButton color="info" href="/positions/positions-list" icon={mdiArrowRight} label="Roles" outline small /> : null}
{canReadTraining ? <BaseButton color="info" href="/training_programs/training_programs-list" icon={mdiArrowRight} label="Training" outline small /> : null}
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-dark-700 dark:bg-dark-800 sm:col-span-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2 text-slate-900 dark:text-white">
<BaseIcon path={mdiBullhornOutline} size={18} />
<span className="font-semibold">Marketing, assets, and AI monitoring</span>
</div>
<div className="mt-3 text-xl font-semibold text-slate-950 dark:text-white">{runningCampaigns} live campaigns</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{plannedCampaigns} planned campaigns, {designAssets.length} design assets, and {aiRuns.length} AI runs are saved in this workspace.
</p>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{latestAiRun
? `Latest AI activity: ${humanize(latestAiRun.run_type || 'run')}${humanize(latestAiRun.status || 'queued')}${formatDate(latestAiRun.finished_at || latestAiRun.started_at || latestAiRun.createdAt)}`
: 'No AI activity has been logged yet for this project.'}
</p>
</div>
<div className="flex flex-wrap gap-2">
{canReadMarketing ? <BaseButton color="info" href="/marketing_campaigns/marketing_campaigns-list" icon={mdiArrowRight} label="Campaigns" outline small /> : null}
{canReadAiRuns ? <BaseButton color="info" href="/ai_runs/ai_runs-list" icon={mdiArrowRight} label="AI runs" outline small /> : null}
</div>
</div>
</div>
</div>
</SectionCard>
</div>
</div> </div>
</> </>
)} )}

View File

@ -111,13 +111,13 @@ const Dashboard = () => {
<> <>
<Head> <Head>
<title> <title>
{getPageTitle('Overview')} {getPageTitle('Admin Overview')}
</title> </title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton <SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant} icon={icon.mdiChartTimelineVariant}
title='Overview' title='Admin Overview'
main> main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>

View File

@ -6,8 +6,15 @@ import CardBox from '../components/CardBox'
import SectionFullScreen from '../components/SectionFullScreen' import SectionFullScreen from '../components/SectionFullScreen'
import LayoutGuest from '../layouts/Guest' import LayoutGuest from '../layouts/Guest'
import { getPageTitle } from '../config' import { getPageTitle } from '../config'
import { useAppSelector } from '../stores/hooks'
import { getPrimaryWorkspaceLabel, getPrimaryWorkspaceRoute } from '../helpers/appNavigation'
export default function Error() { export default function Error() {
const { currentUser } = useAppSelector((state) => state.auth)
const hasSignedInUser = Boolean(currentUser?.id)
const actionHref = hasSignedInUser ? getPrimaryWorkspaceRoute(currentUser) : '/login'
const actionLabel = hasSignedInUser ? `Return to ${getPrimaryWorkspaceLabel(currentUser)}` : 'Go to login'
return ( return (
<> <>
<Head> <Head>
@ -17,12 +24,14 @@ export default function Error() {
<SectionFullScreen bg="pinkRed"> <SectionFullScreen bg="pinkRed">
<CardBox <CardBox
className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl" className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl"
footer={<BaseButton href="/dashboard" label="Done" color="danger" />} footer={<BaseButton href={actionHref} label={actionLabel} color="danger" />}
> >
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-2xl">Unhandled exception</h1> <h1 className="text-2xl">Unhandled exception</h1>
<p>An Error Occurred</p> <p>
Something went wrong. Use the button below to get back to your main workspace and continue from a stable screen.
</p>
</div> </div>
</CardBox> </CardBox>
</SectionFullScreen> </SectionFullScreen>

View File

@ -13,10 +13,12 @@ import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import axios from "axios"; import axios from "axios";
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
export default function Forgot() { export default function Forgot() {
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const router = useRouter(); const router = useRouter();
const { isHydratingStoredSession } = useGuestAuthRedirect();
const notify = (type, msg) => toast( msg, {type}); const notify = (type, msg) => toast( msg, {type});
const handleSubmit = async (value) => { const handleSubmit = async (value) => {
@ -38,11 +40,16 @@ export default function Forgot() {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Forgot password')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> <CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
{isHydratingStoredSession ? (
<div className='mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-6 text-emerald-700'>
Checking your saved session and returning you to your workspace...
</div>
) : null}
<Formik <Formik
initialValues={{ initialValues={{
email: '', email: '',
@ -59,8 +66,9 @@ export default function Forgot() {
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton
type='submit' type='submit'
label={loading ? 'Loading...' : 'Submit' } label={loading || isHydratingStoredSession ? 'Loading...' : 'Submit' }
color='info' color='info'
disabled={loading || isHydratingStoredSession}
/> />
<BaseButton <BaseButton
href={'/login'} href={'/login'}

View File

@ -18,8 +18,11 @@ import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon'; import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import { appTitle, getPageTitle } from '../config';
import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const featureCards = [ const featureCards = [
{ {
@ -89,10 +92,35 @@ const valueBlocks = [
]; ];
export default function HomePage() { export default function HomePage() {
const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const [hasStoredToken, setHasStoredToken] = React.useState(false);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const token = localStorage.getItem('token');
const hasToken = Boolean(token);
setHasStoredToken(hasToken);
if (hasToken && !currentUser) {
dispatch(findMe());
}
}, [currentUser, dispatch]);
const hasActiveSession = Boolean(currentUser) || hasStoredToken;
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser);
const workspaceLabel = currentUser ? primaryWorkspace.label : 'workspace';
const workspaceReturnLabel = `Return to ${workspaceLabel}`;
const currentYear = new Date().getFullYear();
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Legacy Business Builder')}</title> <title>{getPageTitle(appTitle)}</title>
</Head> </Head>
<div className="min-h-screen bg-[#F8FAFC] text-slate-950"> <div className="min-h-screen bg-[#F8FAFC] text-slate-950">
@ -103,15 +131,28 @@ export default function HomePage() {
<BaseIcon path={mdiRobotOutline} size={24} /> <BaseIcon path={mdiRobotOutline} size={24} />
</span> </span>
<div> <div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Legacy Business Builder</div> <div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{appTitle}</div>
<div className="text-sm font-medium text-slate-700">AI-powered launch operating system</div> <div className="text-sm font-medium text-slate-700">AI-powered launch operating system</div>
</div> </div>
</Link> </Link>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<BaseButton href="/login" color="whiteDark" outline label="Login" /> {hasActiveSession ? (
<BaseButton href="/dashboard" color="whiteDark" outline label="Admin interface" /> <div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
<BaseButton href="/legacy-launchpad" color="info" icon={mdiArrowRight} label="Open launchpad" /> <span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
Signed in
</div>
) : (
<BaseButton href="/login" color="whiteDark" outline label="Login" />
)}
<BaseButton
href={hasActiveSession ? primaryWorkspace.href : '/business-command-center'}
color="whiteDark"
outline
icon={hasActiveSession ? primaryWorkspace.icon : undefined}
label={hasActiveSession ? workspaceReturnLabel : 'Founder workspace'}
/>
<BaseButton href="/legacy-launchpad" color="info" icon={mdiArrowRight} label={hasActiveSession ? 'Continue planning' : 'Open launchpad'} />
</div> </div>
</div> </div>
</header> </header>
@ -135,9 +176,27 @@ export default function HomePage() {
property, compliance research, design assets, staffing, training, marketing, and opening-day readiness. property, compliance research, design assets, staffing, training, marketing, and opening-day readiness.
</p> </p>
{hasActiveSession ? (
<div className="mt-8 max-w-3xl rounded-[32px] border border-emerald-400/20 bg-emerald-400/10 p-5 shadow-lg shadow-emerald-950/10">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-emerald-300">Already signed in</div>
<div className="mt-2 text-lg font-semibold text-white">
{currentUser?.firstName ? `${currentUser.firstName}, your workspace is ready.` : 'Your workspace is ready.'}
</div>
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-300">
Skip the public overview and jump back into your saved {workspaceLabel.toLowerCase()} to keep planning, reviewing records,
and moving toward opening day.
</p>
</div>
<BaseButton href={primaryWorkspace.href} color="info" icon={primaryWorkspace.icon} label={workspaceReturnLabel} />
</div>
</div>
) : null}
<div className="mt-8 flex flex-wrap gap-4"> <div className="mt-8 flex flex-wrap gap-4">
<BaseButton href="/legacy-launchpad" color="info" icon={mdiArrowRight} label="Start the launch workflow" /> <BaseButton href="/legacy-launchpad" color="info" icon={mdiArrowRight} label="Start the launch workflow" />
<BaseButton href="/dashboard" color="whiteDark" outline label="Go to admin workspace" /> <BaseButton href="/business-command-center" color="whiteDark" outline label="Open founder workspace" />
</div> </div>
<div className="mt-10 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="mt-10 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@ -236,15 +295,31 @@ export default function HomePage() {
<div className="rounded-[36px] bg-[#0F172A] px-6 py-10 text-white shadow-2xl shadow-slate-900/20 md:px-10"> <div className="rounded-[36px] bg-[#0F172A] px-6 py-10 text-white shadow-2xl shadow-slate-900/20 md:px-10">
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-center"> <div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
<div> <div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Start inside the real app</div> <div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{hasActiveSession ? 'Already inside the app' : 'Start inside the real app'}</div>
<h2 className="mt-3 text-4xl font-semibold tracking-tight">Open the admin interface, then use Legacy Launchpad to create the first working plan.</h2> <h2 className="mt-3 text-4xl font-semibold tracking-tight">
{hasActiveSession
? `Youre already signed in — return to the ${workspaceLabel} and keep the business build moving.`
: 'Sign in to the founder workspace, then use Legacy Launchpad to create the first working plan.'}
</h2>
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-300"> <p className="mt-4 max-w-3xl text-base leading-8 text-slate-300">
The landing page stays public. The planning workflow and entity records stay protected behind login, so your business operating system stays private while you build it. {hasActiveSession
? 'Use the public homepage as a quick overview, then jump back into your protected workspace to review records, run the launch workflow, and keep the opening plan current.'
: 'The landing page stays public. The founder workspace, launch workflow, and entity records stay protected behind login so your business operating system stays private while you build it.'}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-4 lg:justify-end"> <div className="flex flex-wrap gap-4 lg:justify-end">
<BaseButton href="/login" color="info" label="Login" /> <BaseButton
<BaseButton href="/dashboard" color="whiteDark" outline label="Admin interface" /> href={hasActiveSession ? primaryWorkspace.href : '/login'}
color="info"
icon={hasActiveSession ? primaryWorkspace.icon : undefined}
label={hasActiveSession ? workspaceReturnLabel : 'Login'}
/>
<BaseButton
href={hasActiveSession ? '/legacy-launchpad' : '/business-command-center'}
color="whiteDark"
outline
label={hasActiveSession ? 'Continue planning' : 'Founder workspace'}
/>
</div> </div>
</div> </div>
</div> </div>
@ -253,13 +328,16 @@ export default function HomePage() {
<footer className="border-t border-slate-200 bg-white/80"> <footer className="border-t border-slate-200 bg-white/80">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-6 text-sm text-slate-500 md:flex-row md:items-center md:justify-between lg:px-8"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-6 text-sm text-slate-500 md:flex-row md:items-center md:justify-between lg:px-8">
<div>© 2026 Legacy Business Builder. Built to turn founder vision into a lasting family-owned operating system.</div> <div>© {currentYear} {appTitle}. Built to turn founder vision into a lasting family-owned operating system.</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/privacy-policy" className="transition-colors duration-150 hover:text-slate-900"> <Link href="/privacy-policy" className="transition-colors duration-150 hover:text-slate-900">
Privacy Policy Privacy Policy
</Link> </Link>
<Link href="/login" className="transition-colors duration-150 hover:text-slate-900"> <Link href="/terms-of-use" className="transition-colors duration-150 hover:text-slate-900">
Login Terms of Use
</Link>
<Link href={hasActiveSession ? primaryWorkspace.href : '/login'} className="transition-colors duration-150 hover:text-slate-900">
{hasActiveSession ? workspaceReturnLabel : 'Login'}
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -13,6 +13,7 @@ import {
mdiMapMarkerOutline, mdiMapMarkerOutline,
mdiOpenInNew, mdiOpenInNew,
mdiRobotOutline, mdiRobotOutline,
mdiViewDashboardOutline,
mdiScaleBalance, mdiScaleBalance,
mdiUpload, mdiUpload,
} from '@mdi/js'; } from '@mdi/js';
@ -159,12 +160,12 @@ function formatNumber(value: number | string | null | undefined) {
function formatDate(value: string | number | Date | null | undefined) { function formatDate(value: string | number | Date | null | undefined) {
if (!value) { if (!value) {
return 'TBD'; return 'Not scheduled yet';
} }
const parsed = new Date(value); const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) { if (Number.isNaN(parsed.getTime())) {
return 'TBD'; return 'Not scheduled yet';
} }
return dateFormatter.format(parsed); return dateFormatter.format(parsed);
@ -905,8 +906,17 @@ const LegacyLaunchpad = () => {
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
{canReadProjects && ( {canReadProjects && (
<BaseButton <BaseButton
href={`/projects/projects-view/?id=${generatedProject?.id}`} href="/business-command-center"
color="info" color="info"
icon={mdiViewDashboardOutline}
label="Open Command Center"
/>
)}
{canReadProjects && (
<BaseButton
href={`/projects/projects-view/?id=${generatedProject?.id}`}
color="whiteDark"
outline
icon={mdiOpenInNew} icon={mdiOpenInNew}
label="Open project workspace" label="Open project workspace"
/> />

View File

@ -14,16 +14,36 @@ import FormField from '../components/FormField';
import FormCheckRadio from '../components/FormCheckRadio'; import FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider'; import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons'; import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { appTitle, getPageTitle, showDevelopmentCredentials } from '../config';
import { getPageTitle } from '../config'; import { clearAuthError, 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 Link from 'next/link';
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
const developmentAccounts = [
{
email: 'super_admin@flatlogic.com',
password: '15315dc9',
label: 'Super admin',
helper: 'Loads the seeded super administrator account into the form.',
},
{
email: 'admin@flatlogic.com',
password: '15315dc9',
label: 'Administrator',
helper: 'Loads the seeded administrator account into the form.',
},
{
email: 'client@hello.com',
password: 'b033b76a7866',
label: 'Contributor',
helper: 'Loads the seeded contributor account into the form.',
},
];
export default function Login() { export default function Login() {
const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor); const textColor = useAppSelector((state) => state.style.linkColor);
const iconsColor = useAppSelector((state) => state.style.iconsColor); const iconsColor = useAppSelector((state) => state.style.iconsColor);
@ -37,14 +57,17 @@ export default function Login() {
const [contentType, setContentType] = useState('image'); const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('left'); const [contentPosition, setContentPosition] = useState('left');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( const { isHydratingStoredSession } = useGuestAuthRedirect();
const { isFetching, errorMessage, notify:notifyState } = useAppSelector(
(state) => state.auth, (state) => state.auth,
); );
const [initialValues, setInitialValues] = React.useState({ email:'super_admin@flatlogic.com', const [initialValues, setInitialValues] = React.useState({ email: '',
password: '15315dc9', password: '',
remember: true }) remember: true })
const title = 'Legacy Business Builder' const title = appTitle
const currentYear = new Date().getFullYear()
const showQuickLoginHints = showDevelopmentCredentials
// Fetch Pexels image/video // Fetch Pexels image/video
useEffect( () => { useEffect( () => {
@ -56,25 +79,14 @@ export default function Login() {
} }
fetchData(); fetchData();
}, []); }, []);
// Fetch user data
useEffect(() => {
if (token) {
dispatch(findMe());
}
}, [token, dispatch]);
// Redirect to dashboard if user is logged in
useEffect(() => {
if (currentUser?.id) {
router.push('/dashboard');
}
}, [currentUser?.id, router]);
// Show error message if there is one // Show error message if there is one
useEffect(() => { useEffect(() => {
if (errorMessage){ if (errorMessage){
notify('error', errorMessage) notify('error', errorMessage)
dispatch(clearAuthError());
} }
}, [errorMessage]) }, [dispatch, errorMessage])
// Show notification if there is one // Show notification if there is one
useEffect(() => { useEffect(() => {
if (notifyState?.showNotification) { if (notifyState?.showNotification) {
@ -92,11 +104,11 @@ export default function Login() {
await dispatch(loginUser(rest)); await dispatch(loginUser(rest));
}; };
const setLogin = (target: HTMLElement) => { const setDevelopmentLogin = (email: string, password: string) => {
setInitialValues(prev => ({ setInitialValues((prev) => ({
...prev, ...prev,
email : target.innerText.trim(), email,
password: target.dataset.password ?? '', password,
})); }));
}; };
@ -163,34 +175,42 @@ export default function Login() {
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null} {contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> <div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'> {showQuickLoginHints ? (
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<h2 className="text-4xl font-semibold my-4">{title}</h2> <div className='flex flex-row justify-between gap-4 text-gray-500'>
<div className='flex flex-row text-gray-500 justify-between'>
<div> <div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-amber-600'>Development only</p>
<p className='mb-2'>Use{' '} <h2 className='my-3 text-2xl font-semibold text-slate-900'>Quick login shortcuts</h2>
<code className={`cursor-pointer ${textColor} `} <p className='mb-4 max-w-2xl text-sm leading-6 text-slate-600'>
data-password="15315dc9" These seeded accounts are shown only outside production so the workspace can be tested quickly during development.
onClick={(e) => setLogin(e.target)}>super_admin@flatlogic.com</code>{' / '} </p>
<code className={`${textColor}`}>15315dc9</code>{' / '} <div className='space-y-3'>
to login as Super Admin</p> {developmentAccounts.map((account) => (
<div
<p className='mb-2'>Use{' '} key={account.email}
<code className={`cursor-pointer ${textColor} `} className='rounded-2xl border border-amber-100 bg-amber-50/60 p-3'
data-password="15315dc9" >
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '} <div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<code className={`${textColor}`}>15315dc9</code>{' / '} <div>
to login as Admin</p> <p className='font-semibold text-slate-900'>{account.label}</p>
<p>Use <code <p className='text-xs leading-5 text-slate-500'>{account.helper}</p>
className={`cursor-pointer ${textColor} `} </div>
data-password="b033b76a7866" <div className='flex flex-col items-start gap-2 sm:items-end'>
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '} <BaseButton
<code className={`${textColor}`}>b033b76a7866</code>{' / '} small
to login as User</p> outline
color='info'
label={`Fill ${account.label}`}
onClick={() => setDevelopmentLogin(account.email, account.password)}
/>
<code className={`${textColor} text-xs`}>Password: {account.password}</code>
</div>
</div>
</div>
))}
</div>
</div> </div>
<div> <div className='hidden sm:block'>
<BaseIcon <BaseIcon
className={`${iconsColor}`} className={`${iconsColor}`}
w='w-16' w='w-16'
@ -200,9 +220,22 @@ export default function Login() {
/> />
</div> </div>
</div> </div>
</CardBox> </CardBox>
) : null}
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <CardBox className='w-full md:w-3/5 lg:w-2/3'>
<div className='mb-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#4F46E5]'>{title}</p>
<h1 className='mt-2 text-3xl font-semibold tracking-tight text-slate-950'>Sign in to your workspace</h1>
<p className='mt-2 text-sm leading-6 text-slate-600'>
Continue planning your launch, review saved records, and keep the business moving toward opening day.
</p>
</div>
{isHydratingStoredSession ? (
<div className='mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-6 text-emerald-700'>
Checking your saved session and returning you to your workspace...
</div>
) : null}
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
enableReinitialize enableReinitialize
@ -210,8 +243,8 @@ export default function Login() {
> >
<Form> <Form>
<FormField <FormField
label='Login' label='Email address'
help='Please enter your login'> help='Please enter your email address'>
<Field name='email' /> <Field name='email' />
</FormField> </FormField>
@ -249,16 +282,16 @@ export default function Login() {
<BaseButton <BaseButton
className={'w-full'} className={'w-full'}
type='submit' type='submit'
label={isFetching ? 'Loading...' : 'Login'} label={isFetching || isHydratingStoredSession ? 'Loading...' : 'Sign in'}
color='info' color='info'
disabled={isFetching} disabled={isFetching || isHydratingStoredSession}
/> />
</BaseButtons> </BaseButtons>
<br /> <br />
<p className={'text-center'}> <p className={'text-center'}>
Dont have an account yet?{' '} Dont have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}> <Link className={`${textColor}`} href={'/register'}>
New Account Create account
</Link> </Link>
</p> </p>
</Form> </Form>
@ -267,11 +300,16 @@ export default function Login() {
</div> </div>
</div> </div>
</SectionFullScreen> </SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <div className='bg-black text-white flex flex-col items-center justify-center gap-2 text-center md:flex-row md:gap-4'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p> <p className='py-4 text-sm'>© {currentYear} <span>{title}</span>. All rights reserved.</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> <div className='flex items-center gap-4 pb-4 md:pb-0'>
Privacy Policy <Link className='text-sm transition-colors duration-150 hover:text-slate-300' href='/privacy-policy'>
</Link> Privacy Policy
</Link>
<Link className='text-sm transition-colors duration-150 hover:text-slate-300' href='/terms-of-use'>
Terms of Use
</Link>
</div>
</div> </div>
<ToastContainer /> <ToastContainer />
</div> </div>

View File

@ -1,11 +1,14 @@
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 Link from 'next/link';
import GuestPageReturnBar from '../components/GuestPageReturnBar';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { appTitle, getPageTitle, publicSupportEmail } from '../config';
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const title = 'Legacy Business Builder' const title = appTitle;
const supportEmail = publicSupportEmail.trim();
const [projectUrl, setProjectUrl] = useState(''); const [projectUrl, setProjectUrl] = useState('');
useEffect(() => { useEffect(() => {
@ -21,7 +24,7 @@ export default function PrivacyPolicy() {
We at <span>{title}</span> ("we", "us", "our") are committed to We at <span>{title}</span> ("we", "us", "our") are committed to
protecting your privacy. This Privacy Policy explains how we collect, protecting your privacy. This Privacy Policy explains how we collect,
use, disclose, and safeguard your information when you visit our use, disclose, and safeguard your information when you visit our
website <a href={projectUrl}>{projectUrl}</a>, use our services, or website {projectUrl ? <a href={projectUrl}>{projectUrl}</a> : 'for this application'}, use our services, or
interact with us in other ways. By using our services, you agree to interact with us in other ways. By using our services, you agree to
the collection and use of information in accordance with this policy. the collection and use of information in accordance with this policy.
</p> </p>
@ -248,14 +251,22 @@ export default function PrivacyPolicy() {
If you have any questions about this Privacy Policy, please contact If you have any questions about this Privacy Policy, please contact
us: us:
</p> </p>
<div> {supportEmail ? (
By email:{' '} <>
<a href='mailto:support@flatlogic.com'> [support@flatlogic.com]</a> <div>
</div> By email:{' '}
<div> <a href={`mailto:${supportEmail}`}>{supportEmail}</a>
By visiting this page on our website:{' '} </div>
<a href='https://flatlogic.com/contact'>Contact Us</a> <div>
</div> By visiting the homepage:{' '}
<Link href='/'>Homepage</Link>
</div>
</>
) : (
<div>
Please use the business contact details published on the <Link href='/'>homepage</Link>.
</div>
)}
</> </>
); );
}; };
@ -266,6 +277,8 @@ export default function PrivacyPolicy() {
<title>{getPageTitle('Privacy Policy')}</title> <title>{getPageTitle('Privacy Policy')}</title>
</Head> </Head>
<GuestPageReturnBar title='Privacy Policy' />
<div className='flex justify-center'> <div className='flex justify-center'>
<div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'> <div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'>
<div className='p-8 lg:px-12 lg:py-10'> <div className='p-8 lg:px-12 lg:py-10'>

View File

@ -22,6 +22,7 @@ import BaseButton from '../components/BaseButton';
import FormCheckRadio from '../components/FormCheckRadio'; import FormCheckRadio from '../components/FormCheckRadio';
import FormCheckRadioGroup from '../components/FormCheckRadioGroup'; import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
import FormImagePicker from '../components/FormImagePicker'; import FormImagePicker from '../components/FormImagePicker';
import ImageField from '../components/ImageField';
import { SwitchField } from '../components/SwitchField'; import { SwitchField } from '../components/SwitchField';
import { SelectField } from '../components/SelectField'; import { SelectField } from '../components/SelectField';
@ -83,9 +84,12 @@ const EditUsers = () => {
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}> {currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8"> <ImageField
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" /> name='Avatar'
</div> image={currentUser.avatar}
className='col-span-1 mb-8 h-80 w-80 overflow-hidden rounded-full border-2'
imageClassName='h-full object-cover object-center'
/>
</div>} </div>}
<Formik <Formik
enableReinitialize enableReinitialize

View File

@ -18,6 +18,7 @@ import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from "axios"; import axios from "axios";
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
export default function Register() { export default function Register() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
@ -27,6 +28,7 @@ export default function Register() {
const [organizations, setOrganizations] = React.useState(null); const [organizations, setOrganizations] = React.useState(null);
const [selectedOrganization, setSelectedOrganization] = React.useState(null); const [selectedOrganization, setSelectedOrganization] = React.useState(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { isHydratingStoredSession } = useGuestAuthRedirect();
const fetchOrganizations = createAsyncThunk( const fetchOrganizations = createAsyncThunk(
'/org-for-auth', '/org-for-auth',
async () => { async () => {
@ -69,11 +71,16 @@ export default function Register() {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Register')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> <CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
{isHydratingStoredSession ? (
<div className='mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-6 text-emerald-700'>
Checking your saved session and returning you to your workspace...
</div>
) : null}
<Formik <Formik
initialValues={{ initialValues={{
email: '', email: '',
@ -111,8 +118,9 @@ export default function Register() {
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton
type='submit' type='submit'
label={loading ? 'Loading...' : 'Register' } label={loading || isHydratingStoredSession ? 'Loading...' : 'Register' }
color='info' color='info'
disabled={loading || isHydratingStoredSession}
/> />
<BaseButton <BaseButton
href={'/login'} href={'/login'}

View File

@ -15,6 +15,7 @@ import BaseDivider from '../components/BaseDivider';
import { mdiChartTimelineVariant } from '@mdi/js'; import { mdiChartTimelineVariant } from '@mdi/js';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';
import { getPrimaryWorkspaceLabel, getPrimaryWorkspaceRoute } from '../helpers/appNavigation';
const SearchView = () => { const SearchView = () => {
const router = useRouter(); const router = useRouter();
@ -25,6 +26,8 @@ const SearchView = () => {
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const organizationsId = currentUser?.organizations?.id; const organizationsId = currentUser?.organizations?.id;
const backHref = getPrimaryWorkspaceRoute(currentUser);
const backLabel = `Back to ${getPrimaryWorkspaceLabel(currentUser)}`;
useEffect(() => { useEffect(() => {
@ -73,8 +76,8 @@ const SearchView = () => {
<BaseDivider /> <BaseDivider />
<BaseButton <BaseButton
color='info' color='info'
label='Back' href={backHref}
onClick={() => router.push('/dashboard')} label={backLabel}
/> />
</CardBox> </CardBox>
</SectionMain> </SectionMain>

View File

@ -1,16 +1,14 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link';
import GuestPageReturnBar from '../components/GuestPageReturnBar';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { appTitle, getPageTitle, publicSupportEmail } from '../config';
export default function PrivacyPolicy() { export default function TermsOfUse() {
const title = 'Legacy Business Builder'; const title = appTitle;
const [projectUrl, setProjectUrl] = useState(''); const supportEmail = publicSupportEmail.trim();
useEffect(() => {
setProjectUrl(location.origin);
}, []);
const Information = () => { const Information = () => {
return ( return (
@ -164,10 +162,16 @@ export default function PrivacyPolicy() {
return ( return (
<> <>
<h3>11. Contact Information</h3> <h3>11. Contact Information</h3>
<p> {supportEmail ? (
If you have any questions about these Terms of Use, please contact us <p>
at: <a href='mailto:support@flatlogic.com'> [support@flatlogic.com]</a> If you have any questions about these Terms of Use, please contact us
</p> at: <a href={`mailto:${supportEmail}`}>{supportEmail}</a>
</p>
) : (
<p>
If you have any questions about these Terms of Use, please use the business contact details published on the <Link href='/'>homepage</Link>.
</p>
)}
</> </>
); );
}; };
@ -178,6 +182,8 @@ export default function PrivacyPolicy() {
<title>{getPageTitle('Terms of Use')}</title> <title>{getPageTitle('Terms of Use')}</title>
</Head> </Head>
<GuestPageReturnBar title='Terms of Use' />
<div className='flex justify-center'> <div className='flex justify-center'>
<div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'> <div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'>
<div className='p-8 lg:px-12 lg:py-10'> <div className='p-8 lg:px-12 lg:py-10'>
@ -201,6 +207,6 @@ export default function PrivacyPolicy() {
); );
} }
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) { TermsOfUse.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -1,62 +1,129 @@
import React from 'react'; import React from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head'; import Head from 'next/head';
import axios from 'axios';
import { useRouter } from 'next/router';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen'; import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import axios from 'axios'; import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation';
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
import LayoutGuest from '../layouts/Guest';
type VerificationStatus = 'loading' | 'success' | 'error' | 'missing-token';
export default function Verify() { export default function Verify() {
const [loading, setLoading] = React.useState(false); const router = useRouter();
const router = useRouter(); const [status, setStatus] = React.useState<VerificationStatus>('loading');
const { token } = router.query; const [message, setMessage] = React.useState('Checking your verification link...');
const notify = (type, msg) => toast(msg, { type });
React.useEffect(() => { const tokenQuery = router.query.token;
if (!token) { const token = typeof tokenQuery === 'string' ? tokenQuery : Array.isArray(tokenQuery) ? tokenQuery[0] ?? '' : '';
router.push('/login'); const hasToken = Boolean(token);
return;
const { currentUser, isHydratingStoredSession } = useGuestAuthRedirect({
enabled: router.isReady && !hasToken,
});
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser);
const isRedirectingSignedInUser = router.isReady && !hasToken && Boolean(currentUser?.id);
React.useEffect(() => {
if (!router.isReady) {
return;
}
if (!hasToken) {
setStatus('missing-token');
setMessage('This email verification link is missing or incomplete. Please use the full link from your email.');
return;
}
let isMounted = true;
const verifyEmail = async () => {
setStatus('loading');
setMessage('Verifying your email address...');
try {
await axios.put('/auth/verify-email', {
token,
});
if (!isMounted) {
return;
} }
const handleSubmit = async () => {
setLoading(true); setStatus('success');
await axios.put('/auth/verify-email', { setMessage('Your email address has been verified successfully.');
token, } catch (error) {
}).then(verified => { console.error('Email verification failed:', error);
if (verified) {
setLoading(false);
notify('success', 'Your email was verified');
}
}).catch(error => {
setLoading(false);
console.log('error: ', error);
notify('error', error.response);
}).finally(async () => {
await router.push('/login');
});
};
handleSubmit().then();
}, [token]);
return ( const responseMessage = typeof (error as any)?.response?.data === 'string' ? (error as any).response.data : '';
<> const nextMessage = responseMessage || 'This email verification link is invalid or has expired.';
<Head>
<title>{getPageTitle('Verify Email')}</title>
</Head>
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<p>{loading ? 'Loading...' : ''}</p>
</CardBox>
</SectionFullScreen>
<ToastContainer /> if (!isMounted) {
</> return;
); }
setStatus('error');
setMessage(nextMessage);
}
};
verifyEmail();
return () => {
isMounted = false;
};
}, [hasToken, router.isReady, token]);
const messageToneClass =
status === 'success'
? 'border border-emerald-200 bg-emerald-50 text-emerald-700'
: status === 'error' || status === 'missing-token'
? 'border border-amber-200 bg-amber-50 text-amber-800'
: 'border border-blue-200 bg-blue-50 text-blue-700';
return (
<>
<Head>
<title>{getPageTitle('Verify Email')}</title>
</Head>
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<p className='mb-2 text-xl'>Verify Email</p>
<p className='mb-4 text-base'>Well confirm your email address and then give you the next best step.</p>
{isHydratingStoredSession || isRedirectingSignedInUser ? (
<div className='mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-6 text-emerald-700'>
Checking your saved session and returning you to your workspace...
</div>
) : null}
<div className={`rounded-2xl px-4 py-3 text-sm leading-6 ${messageToneClass}`}>
{message}
</div>
{status === 'success' || status === 'error' || status === 'missing-token' ? (
<BaseButtons mb='mb-0' className='mt-4'>
{currentUser?.id ? (
<BaseButton href={primaryWorkspace.href} label={`Return to ${primaryWorkspace.label}`} color='info' />
) : (
<BaseButton href='/login' label='Login' color='info' />
)}
<BaseButton href='/' label='Homepage' color='whiteDark' outline />
</BaseButtons>
) : null}
</CardBox>
</SectionFullScreen>
</>
);
} }
Verify.getLayout = function getLayout(page: ReactElement) { Verify.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -77,6 +77,9 @@ export const authSlice = createSlice({
state.currentUser = null; state.currentUser = null;
state.token = ''; state.token = '';
}, },
clearAuthError: (state) => {
state.errorMessage = '';
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(loginUser.pending, (state) => { builder.addCase(loginUser.pending, (state) => {
@ -105,7 +108,8 @@ export const authSlice = createSlice({
state.isFetching = false; state.isFetching = false;
}); });
builder.addCase(passwordReset.fulfilled, (state, action) => { builder.addCase(passwordReset.fulfilled, (state) => {
state.errorMessage = '';
state.notify.showNotification = true; state.notify.showNotification = true;
state.notify.textNotification = 'Password has been reset successfully'; state.notify.textNotification = 'Password has been reset successfully';
}); });
@ -113,12 +117,12 @@ export const authSlice = createSlice({
builder.addCase(resetAction, (state) => initialState); builder.addCase(resetAction, (state) => initialState);
builder.addCase(passwordReset.rejected, (state) => { builder.addCase(passwordReset.rejected, (state) => {
state.errorMessage = 'Something was wrong. Try again'; state.errorMessage = '';
}); });
}, },
}); });
// Action creators are generated for each case reducer function // Action creators are generated for each case reducer function
export const { logoutUser } = authSlice.actions; export const { clearAuthError, logoutUser } = authSlice.actions;
export default authSlice.reducer; export default authSlice.reducer;

View File

@ -1,139 +1,13 @@
interface Step { export type Step = {
element: string; element: string;
intro: string; intro: string;
position?: string; position?: string;
tooltipClass?: string; tooltipClass?: string;
highlightClass?: string; highlightClass?: string;
disableInteraction?: boolean; disableInteraction?: boolean;
} };
interface Hint {
element: string;
hint: string;
hintPosition?: string;
}
export const loginSteps: Step[] = [
{
element: '#elementId1',
intro: `
<div class="text-center text-black ">
<img src="https://flatlogic.com/blog/wp-content/uploads/2024/10/good_img.png" alt="Description" class="w-full mb-2 object-cover" />
<p>Welcome to our app tutorial! Get a sneak peek into the key functionalities and learn how to navigate seamlessly. Here's a quick overview to get you started.</p>
</div>
`,
position: 'auto',
tooltipClass: ' good-img',
},
{
element: '#loginRoles',
intro:
'Choose your login role to proceed. Experience the app as a Super Admin, Admin, or User, or create your own account to get started.',
position: 'auto',
},
];
export const appSteps: Step[] = [
{
element: '#profilEdit',
intro:
"Update your profile information, including name, email, and password. Don't forget to save your changes to keep your profile current.",
position: 'auto',
disableInteraction: true,
},
{
element: '#themeToggle',
intro: 'Switch between light and dark modes to suit your preference.',
position: 'auto',
disableInteraction: true,
},
{
element: '#logout',
intro:
'Log out or switch users/roles with ease to manage your access.',
position: 'auto',
disableInteraction: true,
},
{
element: '#search',
intro:
'Quickly find specific data or items by entering your query in the search field. Navigate directly to the desired element.',
position: 'auto',
disableInteraction: true,
},
{
element: '#widgetCreator',
intro:
'Use Text-to-Chart and Text-to-Widget to create charts or widgets from text descriptions. Type what you need, like "Orders by Month," and customize your dashboard.',
position: 'auto',
disableInteraction: true,
},
{
element: '#dashboard',
intro:
'View all the entities available to your role, offering insights into the data categories and total items in each.',
position: 'auto',
disableInteraction: true,
},
{
element: '#asideMenu',
intro:
'Access various entities and manage your data. Find links to Swagger API documentation for more information.',
position: 'auto',
disableInteraction: true,
},
{
element: '#asideMenu',
intro: "Let's explore the User entity.",
position: 'auto',
disableInteraction: true,
},
];
export const usersSteps: Step[] = [
{
element: '#usersList',
intro:
'Invite new users, filter data, and work with CSV files in this section.',
position: 'auto',
disableInteraction: true,
},
{
element: '#usersTable',
intro:
'View, modify, or delete items with the necessary permissions. Inline editing is available within the table.',
position: 'auto',
disableInteraction: true,
},
{
element: '#asideMenu',
intro: "Let's explore the Roles entity.",
position: 'auto',
disableInteraction: true,
},
];
export const rolesSteps: Step[] = [
{
element: '#rolesTable',
intro:
'Super Admin can manage roles and permissions. Adjust access levels and permissions for each role or user in the Roles and Permissions sections.',
position: 'auto',
disableInteraction: true,
},
{
element: '#feedbackSection',
intro: `
<div class="text-center ">
<img src="https://flatlogic.com/blog/wp-content/uploads/2024/10/end_guide.png" alt="Description" class="w-full mb-2 object-cover" />
<p>Thank you for completing the tour! We hope you now have a better understanding of the app.</p>
<p>If you have any questions, feel free to reach out to us at <a href="mailto:support@flatlogic.com" class="text-blue-500 underline">support@flatlogic.com</a>.</p>
</div>
`,
position: 'auto',
tooltipClass: 'end-img',
},
];
export const loginSteps: Step[] = [];
export const appSteps: Step[] = [];
export const usersSteps: Step[] = [];
export const rolesSteps: Step[] = [];