Autosave: 20260503-111326
This commit is contained in:
parent
677de2a77b
commit
44376dbb51
@ -907,6 +907,8 @@ module.exports = class UsersDBApi {
|
||||
{
|
||||
password,
|
||||
authenticationUid: id,
|
||||
passwordResetToken: null,
|
||||
passwordResetTokenExpiresAt: null,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
9
frontend/public/robots.txt
Normal file
9
frontend/public/robots.txt
Normal 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
|
||||
17
frontend/public/site.webmanifest
Normal file
17
frontend/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
©{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">© {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>
|
||||
)
|
||||
|
||||
63
frontend/src/components/GuestPageReturnBar.tsx
Normal file
63
frontend/src/components/GuestPageReturnBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 || ''
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
49
frontend/src/helpers/appNavigation.ts
Normal file
49
frontend/src/helpers/appNavigation.ts
Normal 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;
|
||||
}
|
||||
75
frontend/src/hooks/useGuestAuthRedirect.ts
Normal file
75
frontend/src/hooks/useGuestAuthRedirect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
33
frontend/src/pages/404.tsx
Normal file
33
frontend/src/pages/404.tsx
Normal 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 doesn’t 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>
|
||||
}
|
||||
33
frontend/src/pages/500.tsx
Normal file
33
frontend/src/pages/500.tsx
Normal 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>
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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
|
||||
? `You’re 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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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'}>
|
||||
Don’t 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>
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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'>We’ll 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>;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user