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,
authenticationUid: id,
passwordResetToken: null,
passwordResetTokenExpiresAt: null,
updatedById: currentUser.id,
},
{ transaction },

View File

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

View File

@ -10,6 +10,11 @@ const config = require('../config');
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');
@ -86,9 +91,7 @@ router.use(checkCrudPermissions('users'));
* description: Some server error
*/
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await UsersService.create(req.body.data, req.currentUser, true, link.host);
await UsersService.create(req.body.data, req.currentUser, true, getRequestOrigin(req));
const payload = true;
res.status(200).send(payload);
}));
@ -129,9 +132,7 @@ router.post('/', wrapAsync(async (req, res) => {
*
*/
router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await UsersService.bulkImport(req, res, true, link.host);
await UsersService.bulkImport(req, res, true, getRequestOrigin(req));
const payload = true;
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 { mdiLogout, mdiClose } from '@mdi/js'
import { mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
@ -7,7 +7,8 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { appTitle } from '../config';
import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation';
type Props = {
menu: MenuAsideItem[]
@ -31,6 +32,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
const { currentUser } = useAppSelector((state) => state.auth);
const organizationsId = currentUser?.organizations?.id;
const [organizations, setOrganizations] = React.useState(null);
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser);
const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => {
try {
@ -52,6 +54,9 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
organizationName = organizationName?.substring(0, 25) + '...';
}
const brandSubLabel = organizationName
? `${organizationName} · Open ${primaryWorkspace.label}`
: `Workspace home · Open ${primaryWorkspace.label}`;
return (
<aside
@ -64,13 +69,18 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div
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">
<b className="font-black">Legacy Business Builder</b>
{organizationName && <p>{organizationName}</p>}
<div className="flex-1 px-4 text-center lg:text-left xl:text-center">
<Link
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"
>
<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>
<button
className="hidden lg:inline-block xl:hidden p-3"

View File

@ -1,5 +1,6 @@
import React, { ReactNode } from 'react'
import { containerMaxW } from '../config'
import Link from 'next/link'
import { appTitle, containerMaxW } from '../config'
import Logo from './Logo'
type Props = {
@ -10,25 +11,29 @@ export default function FooterBar({ children }: Props) {
const year = new Date().getFullYear()
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="text-center md:text-left mb-6 md:mb-0">
<b>
&copy;{year},{` `}
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
Flatlogic
</a>
.
</b>
{` `}
{children}
<div className="mb-4 text-center md:mb-0 md:text-left">
<div className="text-sm font-semibold text-slate-900 dark:text-white">&copy; {year} {appTitle}.</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{children || 'Protected founder workspace for planning, operations, and launch readiness.'}
</div>
</div>
<div className="flex item-center md:py-2 gap-4">
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
<Logo className="w-auto h-8 md:h-6 mx-auto" />
</a>
</div>
<div className="flex flex-wrap items-center justify-center gap-4 md:justify-end">
<Link href="/" className="text-sm text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
Home
</Link>
<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>
</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 { appTitle } from '../../config'
type Props = {
className?: string
}
export default function Logo({ className = '' }: Props) {
const initials = appTitle
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join('')
.toUpperCase()
return (
<img
src={"https://flatlogic.com/logo.svg"}
className={className}
alt={'Flatlogic logo'}>
</img>
<span
aria-hidden="true"
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}`}
>
{initials}
</span>
)
}

View File

@ -1,113 +1,188 @@
import React from 'react';
import {toast, ToastContainer} from 'react-toastify';
import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
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 { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router';
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 {useAppDispatch} from '../stores/hooks';
import { useAppDispatch } from '../stores/hooks';
type PasswordResetValues = {
password: string;
confirm: string;
};
export default function PasswordSetOrReset() {
const [loading, setLoading] = React.useState(false);
const [isInvitation, setIsInvitation] = React.useState(false);
const router = useRouter();
const {token, invitation} = router.query;
const [loading, setLoading] = React.useState(false);
const [formError, setFormError] = React.useState('');
const router = useRouter();
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();
React.useEffect(() => {
if (invitation) {
setIsInvitation(true);
}
}, [invitation]);
const hasToken = Boolean(token);
const isInvitation = Boolean(invitation) && invitation !== 'false';
const title = isInvitation ? 'Set Password' : 'Reset Password';
const introText = isInvitation
? 'Create a password to activate this account.'
: 'Enter a new password for your account.';
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) => {
setLoading(true);
if (typeof token === 'string') {
await dispatch(
passwordReset({
token,
password: value.password,
type: isInvitation && 'invitation',
}),
);
await router.push('/login');
}
const { currentUser, isHydratingStoredSession } = useGuestAuthRedirect({
enabled: router.isReady && !hasToken,
});
setLoading(false);
};
const isRedirectingSignedInUser = router.isReady && !hasToken && Boolean(currentUser?.id);
const shouldShowMissingLinkState =
router.isReady && !hasToken && !isHydratingStoredSession && !currentUser?.id;
return (
<>
<Head>
{isInvitation && <title>{getPageTitle('Set Password')}</title>}
{!isInvitation && <title>{getPageTitle('Reset Password')}</title>}
</Head>
const handleSubmit = async (values: PasswordResetValues) => {
if (!hasToken) {
setFormError(missingLinkMessage);
return;
}
<SectionFullScreen bg='violet'>
<div className='w-full flex flex-col items-center justify-center'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
{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>
if (!values.password) {
setFormError('Please enter a new password.');
return;
}
<Formik
initialValues={{
password: '',
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>
if (values.password !== values.confirm) {
setFormError('Your passwords do not match. Please re-enter them.');
return;
}
<BaseButtons>
<BaseButton
className='w-full mt-3'
type='submit'
disabled={loading}
label={
loading
? 'Loading...'
: isInvitation
? 'Set Password'
: 'Reset Password'
}
color='info'
/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
setLoading(true);
setFormError('');
try {
await dispatch(
passwordReset({
token,
password: values.password,
type: isInvitation ? 'invitation' : undefined,
}),
).unwrap();
await router.push('/login');
} catch (error) {
console.error('Password reset failed:', error);
const nextError = typeof error === 'string' && error ? error : invalidLinkMessage;
setFormError(nextError);
toast.error(nextError);
} 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>
</SectionFullScreen>
<ToastContainer/>
</>
);
<BaseButtons mb='mb-0'>
{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}
>
{({ errors, touched, values }) => (
<Form style={{width: '300px'}} >
<Form className='w-40 sm:w-52 md:w-56 lg:w-60 xl:w-72'>
<Field
id='search'
name='search'
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`}
/>
{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 portApi = process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API ? 8080 : '';
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 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 || ''

View File

@ -1,7 +1,6 @@
@import "tailwind/_base.css";
@import "tailwind/_components.css";
@import "tailwind/_utilities.css";
@import 'intro.js/introjs.css';
@import "_checkbox-radio-switch.css";
@import "_progress.css";
@import "_scrollbars.css";
@ -11,25 +10,3 @@
@import '_select-dropdown.css';
@import "_theme.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 AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar'
import BaseButton from '../components/BaseButton'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice";
import {hasPermission} from "../helpers/userPermissions";
import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation'
type Props = {
@ -65,6 +67,7 @@ export default function LayoutAuthenticated({
const darkMode = useAppSelector((state) => state.style.darkMode)
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = 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`}
>
<NavBar
menu={menuNavBar}
menu={menuNavBar(currentUser)}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
>
<NavBarItemPlain
@ -110,6 +113,24 @@ export default function LayoutAuthenticated({
>
<BaseIcon path={mdiMenu} size="24" />
</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>
<Search />
</NavBarItemPlain>
@ -121,7 +142,7 @@ export default function LayoutAuthenticated({
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
<FooterBar>Private founder workspace for planning, operations, and launch readiness.</FooterBar>
</div>
</div>
)

View File

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

View File

@ -1,53 +1,53 @@
import {
mdiMenu,
mdiClockOutline,
mdiCloud,
mdiCrop,
mdiAccount,
mdiCogOutline,
mdiEmail,
mdiLogout,
mdiThemeLightDark,
mdiGithub,
mdiVuejs,
} from '@mdi/js'
import { MenuNavBarItem } from './interfaces'
import { getPrimaryWorkspaceMeta, type PermissionAwareUser } from './helpers/appNavigation'
const menuNavBar: MenuNavBarItem[] = [
{
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,
},
]
const menuNavBar = (currentUser?: PermissionAwareUser): MenuNavBarItem[] => {
const primaryWorkspace = getPrimaryWorkspaceMeta(currentUser)
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

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 { NextPage } from 'next';
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 ErrorBoundary from "../components/ErrorBoundary";
import DevModeBadge from '../components/DevModeBadge';
import 'intro.js/introjs.css';
import { Provider } from 'react-redux';
import axios from 'axios';
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 IntroGuide from '../components/IntroGuide';
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
import { store } from '../stores/store';
// Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
? process.env.NEXT_PUBLIC_BACK_API
: baseURLApi;
? process.env.NEXT_PUBLIC_BACK_API
: baseURLApi;
axios.defaults.headers.common['Content-Type'] = 'application/json';
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode
}
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout
}
Component: NextPageWithLayout;
};
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
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(() => {
// Tour is disabled by default in generated projects.
return;
const isCompleted = (stepKey: string) => {
return localStorage.getItem(`completed_${stepKey}`) === 'true';
const interceptorId = axios.interceptors.request.use(
(config) => {
if (typeof window === 'undefined') {
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');
setStepsEnabled(true);
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
setTimeout(() => {
setSteps(appSteps);
setStepName('appSteps');
setStepsEnabled(true);
}, 1000);
} else if (router.pathname === '/users/users-list' && !isCompleted('usersSteps')) {
setTimeout(() => {
setSteps(usersSteps);
setStepName('usersSteps');
setStepsEnabled(true);
}, 1000);
} else if (router.pathname === '/roles/roles-list' && !isCompleted('rolesSteps')) {
setTimeout(() => {
setSteps(rolesSteps);
setStepName('rolesSteps');
setStepsEnabled(true);
}, 1000);
} else {
setSteps([]);
setStepsEnabled(false);
}, []);
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 = 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 = () => {
setStepsEnabled(false);
};
try {
return new URL(router.asPath || '/', publicSiteUrl).toString();
} catch (error) {
return publicSiteUrl;
}
}, [router.asPath]);
const title = 'Legacy Business Builder'
const description = "AI-powered workflow to plan, fund, build, comply, staff, market, and launch a business from an idea."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/39850/app-hero-20260501-064335.png"
const imageWidth = '1920'
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 (
<Provider store={store}>
{getLayout(
<>
<Head>
<meta name="description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:site_name" content="https://flatlogic.com/" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta name="application-name" content={appTitle} />
<meta name="description" content={appDescription} />
<meta name="theme-color" content="#4f46e5" />
{canonicalUrl ? <link rel="canonical" href={canonicalUrl} /> : null}
{canonicalUrl ? <meta property="og:url" content={canonicalUrl} /> : null}
<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:type" content="image/png" />
<meta property="og:image:width" content={imageWidth} />
<meta property="og:image:height" content={imageHeight} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:title" content={appTitle} />
<meta property="twitter:description" content={appDescription} />
<meta property="twitter:image:src" content={image} />
<meta property="twitter:image:width" content={imageWidth} />
<meta property="twitter:image:height" content={imageHeight} />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" href="/favicon.svg" />
</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide
steps={steps}
stepsName={stepName}
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
</>
)}
</Provider>
)
);
}
export default appWithTranslation(MyApp);

View File

@ -286,12 +286,12 @@ function diffInCalendarDays(from: Date, to: Date) {
function formatDate(value: any) {
if (!value) {
return 'TBD';
return 'Not scheduled yet';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'TBD';
return 'Not scheduled yet';
}
return dateFormatter.format(parsed);
@ -3746,7 +3746,11 @@ const BusinessCommandCenter = () => {
<NotificationBar
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"
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={`${approvedDocuments} approved documents, ${publishedTrainingPrograms} published trainings, ${runningCampaigns} live campaigns.`} icon={mdiFileDocumentOutline} label="Launch materials ready" value={approvedDocuments + publishedTrainingPrograms + runningCampaigns} />
</div>
<div className="mb-6">
<SectionCard
action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review saved stage" outline small /> : undefined}
eyebrow="Automatic stage runner"
icon={mdiChartTimelineVariant}
title="Data-driven stage check"
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="Founder controls"
icon={mdiHomeCityOutline}
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">
<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}
@ -4104,7 +4198,17 @@ const BusinessCommandCenter = () => {
</SectionCard>
</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={`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>
@ -4191,7 +4295,7 @@ const BusinessCommandCenter = () => {
</div>
<div className="mb-6 grid gap-6 xl:grid-cols-12">
<div className="xl:col-span-7">
<div className="xl:col-span-12">
<SectionCard
action={projectEditHref ? <BaseButton color="info" href={projectEditHref} icon={mdiArrowRight} label="Review dates" outline small /> : undefined}
eyebrow="Critical path"
@ -5057,7 +5161,7 @@ const BusinessCommandCenter = () => {
</SectionCard>
</div>
<div className="xl:col-span-7">
<div className="xl:col-span-12">
<SectionCard
action={canReadProjectPhases ? <BaseButton color="info" href="/project_phases/project_phases-list" icon={mdiArrowRight} label="Open phases" outline small /> : undefined}
eyebrow="Roadmap"
@ -5091,95 +5195,6 @@ const BusinessCommandCenter = () => {
</SectionCard>
</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>
</>
)}

View File

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

View File

@ -6,8 +6,15 @@ import CardBox from '../components/CardBox'
import SectionFullScreen from '../components/SectionFullScreen'
import LayoutGuest from '../layouts/Guest'
import { getPageTitle } from '../config'
import { useAppSelector } from '../stores/hooks'
import { getPrimaryWorkspaceLabel, getPrimaryWorkspaceRoute } from '../helpers/appNavigation'
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 (
<>
<Head>
@ -17,12 +24,14 @@ export default function Error() {
<SectionFullScreen bg="pinkRed">
<CardBox
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">
<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>
</CardBox>
</SectionFullScreen>

View File

@ -13,10 +13,12 @@ import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import axios from "axios";
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
export default function Forgot() {
const [loading, setLoading] = React.useState(false)
const router = useRouter();
const { isHydratingStoredSession } = useGuestAuthRedirect();
const notify = (type, msg) => toast( msg, {type});
const handleSubmit = async (value) => {
@ -38,11 +40,16 @@ export default function Forgot() {
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('Forgot password')}</title>
</Head>
<SectionFullScreen bg='violet'>
<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
initialValues={{
email: '',
@ -59,8 +66,9 @@ export default function Forgot() {
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Submit' }
label={loading || isHydratingStoredSession ? 'Loading...' : 'Submit' }
color='info'
disabled={loading || isHydratingStoredSession}
/>
<BaseButton
href={'/login'}

View File

@ -18,8 +18,11 @@ import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import { appTitle, getPageTitle } from '../config';
import { getPrimaryWorkspaceMeta } from '../helpers/appNavigation';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { findMe } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const featureCards = [
{
@ -89,10 +92,35 @@ const valueBlocks = [
];
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 (
<>
<Head>
<title>{getPageTitle('Legacy Business Builder')}</title>
<title>{getPageTitle(appTitle)}</title>
</Head>
<div className="min-h-screen bg-[#F8FAFC] text-slate-950">
@ -103,15 +131,28 @@ export default function HomePage() {
<BaseIcon path={mdiRobotOutline} size={24} />
</span>
<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>
</Link>
<div className="flex flex-wrap items-center gap-3">
<BaseButton href="/login" color="whiteDark" outline label="Login" />
<BaseButton href="/dashboard" color="whiteDark" outline label="Admin interface" />
<BaseButton href="/legacy-launchpad" color="info" icon={mdiArrowRight} label="Open launchpad" />
{hasActiveSession ? (
<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">
<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>
</header>
@ -135,9 +176,27 @@ export default function HomePage() {
property, compliance research, design assets, staffing, training, marketing, and opening-day readiness.
</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">
<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 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="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">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>
<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">
{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">
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>
</div>
<div className="flex flex-wrap gap-4 lg:justify-end">
<BaseButton href="/login" color="info" label="Login" />
<BaseButton href="/dashboard" color="whiteDark" outline label="Admin interface" />
<BaseButton
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>
@ -253,13 +328,16 @@ export default function HomePage() {
<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>© 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">
<Link href="/privacy-policy" className="transition-colors duration-150 hover:text-slate-900">
Privacy Policy
</Link>
<Link href="/login" className="transition-colors duration-150 hover:text-slate-900">
Login
<Link href="/terms-of-use" className="transition-colors duration-150 hover:text-slate-900">
Terms of Use
</Link>
<Link href={hasActiveSession ? primaryWorkspace.href : '/login'} className="transition-colors duration-150 hover:text-slate-900">
{hasActiveSession ? workspaceReturnLabel : 'Login'}
</Link>
</div>
</div>

View File

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

View File

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

View File

@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import GuestPageReturnBar from '../components/GuestPageReturnBar';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { appTitle, getPageTitle, publicSupportEmail } from '../config';
export default function PrivacyPolicy() {
const title = 'Legacy Business Builder'
const title = appTitle;
const supportEmail = publicSupportEmail.trim();
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {
@ -21,7 +24,7 @@ export default function PrivacyPolicy() {
We at <span>{title}</span> ("we", "us", "our") are committed to
protecting your privacy. This Privacy Policy explains how we collect,
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
the collection and use of information in accordance with this policy.
</p>
@ -248,14 +251,22 @@ export default function PrivacyPolicy() {
If you have any questions about this Privacy Policy, please contact
us:
</p>
<div>
By email:{' '}
<a href='mailto:support@flatlogic.com'> [support@flatlogic.com]</a>
</div>
<div>
By visiting this page on our website:{' '}
<a href='https://flatlogic.com/contact'>Contact Us</a>
</div>
{supportEmail ? (
<>
<div>
By email:{' '}
<a href={`mailto:${supportEmail}`}>{supportEmail}</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>
</Head>
<GuestPageReturnBar title='Privacy Policy' />
<div className='flex justify-center'>
<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'>

View File

@ -22,6 +22,7 @@ import BaseButton from '../components/BaseButton';
import FormCheckRadio from '../components/FormCheckRadio';
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
import FormImagePicker from '../components/FormImagePicker';
import ImageField from '../components/ImageField';
import { SwitchField } from '../components/SwitchField';
import { SelectField } from '../components/SelectField';
@ -83,9 +84,12 @@ const EditUsers = () => {
</SectionTitleLineWithButton>
<CardBox>
{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">
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" />
</div>
<ImageField
name='Avatar'
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>}
<Formik
enableReinitialize

View File

@ -18,6 +18,7 @@ import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from "axios";
import useGuestAuthRedirect from '../hooks/useGuestAuthRedirect';
export default function Register() {
const [loading, setLoading] = React.useState(false);
@ -27,6 +28,7 @@ export default function Register() {
const [organizations, setOrganizations] = React.useState(null);
const [selectedOrganization, setSelectedOrganization] = React.useState(null);
const dispatch = useAppDispatch();
const { isHydratingStoredSession } = useGuestAuthRedirect();
const fetchOrganizations = createAsyncThunk(
'/org-for-auth',
async () => {
@ -69,11 +71,16 @@ export default function Register() {
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('Register')}</title>
</Head>
<SectionFullScreen bg='violet'>
<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
initialValues={{
email: '',
@ -111,8 +118,9 @@ export default function Register() {
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Register' }
label={loading || isHydratingStoredSession ? 'Loading...' : 'Register' }
color='info'
disabled={loading || isHydratingStoredSession}
/>
<BaseButton
href={'/login'}

View File

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

View File

@ -1,16 +1,14 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import GuestPageReturnBar from '../components/GuestPageReturnBar';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { appTitle, getPageTitle, publicSupportEmail } from '../config';
export default function PrivacyPolicy() {
const title = 'Legacy Business Builder';
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {
setProjectUrl(location.origin);
}, []);
export default function TermsOfUse() {
const title = appTitle;
const supportEmail = publicSupportEmail.trim();
const Information = () => {
return (
@ -164,10 +162,16 @@ export default function PrivacyPolicy() {
return (
<>
<h3>11. Contact Information</h3>
<p>
If you have any questions about these Terms of Use, please contact us
at: <a href='mailto:support@flatlogic.com'> [support@flatlogic.com]</a>
</p>
{supportEmail ? (
<p>
If you have any questions about these Terms of Use, please contact us
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>
</Head>
<GuestPageReturnBar title='Terms of Use' />
<div className='flex justify-center'>
<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'>
@ -201,6 +207,6 @@ export default function PrivacyPolicy() {
);
}
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
TermsOfUse.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,62 +1,129 @@
import React from 'react';
import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify';
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 SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { useRouter } from 'next/router';
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() {
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const { token } = router.query;
const notify = (type, msg) => toast(msg, { type });
const router = useRouter();
const [status, setStatus] = React.useState<VerificationStatus>('loading');
const [message, setMessage] = React.useState('Checking your verification link...');
React.useEffect(() => {
if (!token) {
router.push('/login');
return;
const tokenQuery = router.query.token;
const token = typeof tokenQuery === 'string' ? tokenQuery : Array.isArray(tokenQuery) ? tokenQuery[0] ?? '' : '';
const hasToken = Boolean(token);
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);
await axios.put('/auth/verify-email', {
token,
}).then(verified => {
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]);
setStatus('success');
setMessage('Your email address has been verified successfully.');
} catch (error) {
console.error('Email verification failed:', error);
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>{loading ? 'Loading...' : ''}</p>
</CardBox>
</SectionFullScreen>
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.';
<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) {
return <LayoutGuest>{page}</LayoutGuest>;
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

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

View File

@ -1,139 +1,13 @@
interface Step {
export type Step = {
element: string;
intro: string;
position?: string;
tooltipClass?: string;
highlightClass?: string;
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[] = [];