Autosave: 20260404-164039

This commit is contained in:
Flatlogic Bot 2026-04-04 16:40:40 +00:00
parent d8c8294cc5
commit 77b3bcf3a6
4 changed files with 739 additions and 401 deletions

View File

@ -34,7 +34,7 @@ const FormField = ({ icons = [], ...props }: Props) => {
}
const controlClassName = [
`px-3 py-2 max-w-full border-gray-300 dark:border-dark-700 ${corners} w-full dark:placeholder-gray-400`,
`px-3 py-2 max-w-full w-full ${corners} border-gray-300 text-gray-900 placeholder:text-gray-400 dark:border-dark-700 dark:text-white dark:placeholder-gray-400`,
`${focusRing}`,
props.hasTextareaHeight ? 'h-24' : 'h-12',
props.isBorderless ? 'border-0' : 'border',
@ -54,10 +54,13 @@ const FormField = ({ icons = [], ...props }: Props) => {
</label>
)}
<div className={`${elementWrapperClass}`}>
{Children.map(props.children, (child: ReactElement, index) => (
{Children.map(props.children, (child: ReactElement, index) => {
const childClassName = (child.props as { className?: string })?.className || ''
return (
<div className="relative">
{cloneElement(child as ReactElement<{ className?: string }>, {
className: `${controlClassName} ${icons[index] ? 'pl-10' : ''}`,
className: `${controlClassName} ${childClassName} ${icons[index] ? 'pl-10' : ''}`.trim(),
})}
{icons[index] && (
<BaseIcon
@ -68,7 +71,8 @@ const FormField = ({ icons = [], ...props }: Props) => {
/>
)}
</div>
))}
)
})}
</div>
{props.help && (
<div className='text-xs text-gray-500 dark:text-dark-600 mt-1'>{props.help}</div>

View File

@ -1,77 +1,217 @@
import React from 'react';
import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head';
import Link from 'next/link';
import { Field, Form, Formik } from 'formik';
import { ToastContainer, toast } from 'react-toastify';
import axios from 'axios';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import axios from "axios";
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
type ForgotValues = {
email: string;
};
const TITLE = 'Gracey Corporate Stay Portal';
const RECOVERY_CHIPS = ['Booking access', 'Service access', 'Billing access'];
const AUTH_INPUT_CLASS = '!bg-white !text-stone-900 placeholder:!text-stone-400 dark:!bg-white dark:!text-stone-900 dark:placeholder:!text-stone-400';
export default function Forgot() {
const [loading, setLoading] = React.useState(false)
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const notify = (type, msg) => toast( msg, {type});
const textColor = useAppSelector((state) => state.style.linkColor);
const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' });
const handleSubmit = async (value: ForgotValues) => {
setLoading(true);
const handleSubmit = async (value) => {
setLoading(true)
try {
const { data: response } = await axios.post('/auth/send-password-reset-email', value);
setLoading(false)
notify('success', 'Please check your email for verification link');
await axios.post('/auth/send-password-reset-email', value);
notify('success', 'Please check your email for a password reset link.');
setTimeout(async () => {
await router.push('/login')
}, 3000)
await router.push('/login');
}, 3000);
} catch (error) {
setLoading(false)
console.log('error: ', error)
notify('error', 'Something was wrong. Try again')
console.error('Password reset email request failed:', error);
notify('error', 'Something was wrong. Try again.');
} finally {
setLoading(false);
}
};
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'>
<Formik
initialValues={{
email: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'>
<Field name='email' />
</FormField>
<div className='min-h-screen w-full bg-gradient-to-br from-[#fbf6ee] via-[#f3eadb] to-[#dfc8a5] px-3 py-4 text-stone-900 sm:px-5 sm:py-6 lg:px-8 lg:py-8'>
<div className='mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-7xl flex-col overflow-hidden rounded-[2rem] border border-stone-200/90 bg-white/75 shadow-[0_30px_80px_rgba(68,44,23,0.12)] backdrop-blur md:grid md:grid-cols-[1.04fr_0.96fr]'>
<div className='relative overflow-hidden border-b border-stone-200/80 bg-[linear-gradient(180deg,rgba(255,250,243,0.98),rgba(243,233,216,0.98))] px-5 py-6 sm:px-8 sm:py-8 lg:px-12 lg:py-10 md:border-b-0 md:border-r'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(199,152,87,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.85),transparent_34%)]' />
<BaseDivider />
<div className='relative z-10 flex h-full flex-col justify-between gap-6'>
<div className='space-y-6 sm:space-y-8'>
<div className='space-y-4'>
<p className='text-[11px] font-semibold uppercase tracking-[0.34em] text-stone-500'>
Credential recovery
</p>
<div className='space-y-3'>
<h1 className='max-w-2xl text-3xl font-semibold leading-tight text-stone-950 sm:text-[2.55rem]'>
Restore access without losing your operating context.
</h1>
<p className='max-w-2xl text-sm leading-7 text-stone-700 sm:text-base'>
Request a reset link for the email you use in Gracey and return to the workspace with a fresh
password.
</p>
</div>
<div className='flex flex-wrap gap-2'>
{RECOVERY_CHIPS.map((chip) => (
<span
key={chip}
className='inline-flex items-center rounded-full border border-stone-200 bg-white/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-600'
>
{chip}
</span>
))}
</div>
</div>
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Submit' }
color='info'
/>
<BaseButton
href={'/login'}
label={'Login'}
color='info'
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
<div className='grid gap-3 rounded-[1.75rem] border border-amber-200/70 bg-white/60 p-4 sm:grid-cols-3 sm:p-5'>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Delivery path
</p>
<p className='mt-2 text-sm text-stone-700'>Email-based reset flow</p>
</div>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Access posture
</p>
<p className='mt-2 text-sm text-stone-700'>Private and credential-safe</p>
</div>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Return path
</p>
<p className='mt-2 text-sm text-stone-700'>Back to portal login</p>
</div>
</div>
<div className='rounded-[1.75rem] border border-stone-200/90 bg-white/55 p-5'>
<div className='flex items-center gap-3'>
<span className='h-px w-10 bg-amber-400/80' />
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-stone-600'>Recovery notes</p>
</div>
<div className='mt-4 space-y-4 text-sm leading-7 text-stone-700'>
<p>
Use the same email address you rely on for booking, service, or billing access inside the
workspace.
</p>
<p>
Once the request is submitted, Gracey sends a time-sensitive link so you can create a new
password and re-enter the portal securely.
</p>
<p className='text-stone-600'>
If you no longer have access to the original inbox, contact an internal administrator before
attempting another reset.
</p>
</div>
</div>
</div>
<div className='rounded-[1.5rem] border border-stone-200/90 bg-white/55 p-4 text-sm leading-6 text-stone-600'>
Recovery links are delivered only to recognized account emails and are intended for authorized
operators and customers.
</div>
</div>
</div>
<div className='flex items-center justify-center bg-[linear-gradient(180deg,rgba(255,255,255,0.74),rgba(255,250,242,0.96))] px-4 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10'>
<div className='w-full max-w-xl space-y-5 sm:space-y-6'>
<CardBox className='border border-stone-200/90 bg-white/95 text-stone-950 shadow-[0_22px_55px_rgba(60,38,20,0.08)] dark:!border-stone-200 dark:!bg-white dark:!text-stone-950'>
<div className='space-y-5 sm:space-y-6'>
<div className='space-y-3'>
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-stone-500'>Password reset</p>
<h2 className='text-3xl font-semibold text-stone-950'>Restore your password</h2>
<p className='text-sm leading-6 text-stone-600'>
Enter your email and well send the next secure step to restore access to the Gracey portal.
</p>
<div className='flex flex-wrap gap-2 pt-1'>
{RECOVERY_CHIPS.map((chip) => (
<span
key={chip}
className='inline-flex items-center rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-[11px] font-medium text-stone-600'
>
{chip}
</span>
))}
</div>
</div>
<Formik
initialValues={{
email: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'>
<Field type='email' name='email' className={AUTH_INPUT_CLASS} />
</FormField>
<BaseDivider />
<BaseButtons className='w-full' type='justify-center' classAddon='mb-3' mb='mb-0'>
<BaseButton
className='w-full justify-center border-[#4d3929] bg-[#4d3929] text-white hover:bg-[#3d2d21] focus:ring-amber-300'
type='submit'
label={loading ? 'Loading...' : 'Send reset link'}
color='white'
disabled={loading}
/>
</BaseButtons>
<div className='flex flex-col items-center gap-3 text-center sm:flex-row sm:justify-between sm:text-left'>
<p className='text-sm text-stone-600'>Remembered your password?</p>
<Link className={`${textColor} text-sm font-medium`} href='/login'>
Return to Login
</Link>
</div>
</Form>
</Formik>
</div>
</CardBox>
<div className='flex flex-col items-center justify-center gap-3 border-t border-stone-200 pt-1 text-center text-sm text-stone-600 sm:flex-row'>
<p>© 2026 {TITLE}. © All rights reserved</p>
<div className='flex flex-wrap items-center justify-center gap-4'>
<Link className={`${textColor} text-sm`} href='/privacy-policy/'>
Privacy Policy
</Link>
<Link className={`${textColor} text-sm`} href='/terms-of-use/'>
Terms of Use
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</SectionFullScreen>
<ToastContainer />
</>
);

View File

@ -1,327 +1,378 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { Field, Form, Formik } from 'formik';
import { ToastContainer, toast } from 'react-toastify';
import { mdiEye, mdiEyeOff, mdiInformation } from '@mdi/js';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import FormCheckRadio from '../components/FormCheckRadio';
import FormField from '../components/FormField';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import { findMe, 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 { useRouter } from 'next/router';
type LoginValues = {
email: string;
password: string;
remember: boolean;
};
type DemoAccount = {
email: string;
password: string;
role: string;
accent: string;
summary: string;
};
const DEMO_ACCOUNTS: DemoAccount[] = [
{
email: 'super_admin@flatlogic.com',
password: '946cafba',
role: 'Super Admin',
accent: 'border-stone-300 bg-stone-900 text-white',
summary: 'Global workspace control across organizations and roles.',
},
{
email: 'admin@flatlogic.com',
password: '946cafba',
role: 'Admin',
accent: 'border-amber-200 bg-amber-50 text-stone-900',
summary: 'Day-to-day operating visibility with organization context.',
},
{
email: 'john@doe.com',
password: 'bc40952c93f4',
role: 'Concierge',
accent: 'border-stone-200 bg-white text-stone-900',
summary: 'Service coordination, reservation readiness, and updates.',
},
{
email: 'client@hello.com',
password: 'bc40952c93f4',
role: 'Customer',
accent: 'border-amber-200 bg-white text-stone-900',
summary: 'Customer-side request access and stay visibility.',
},
];
const ACCESS_CHIPS = ['Super Admin', 'Admin', 'Concierge', 'Customer'];
const AUTH_INPUT_CLASS = '!bg-white !text-stone-900 placeholder:!text-stone-400 dark:!bg-white dark:!text-stone-900 dark:placeholder:!text-stone-400';
const DEFAULT_LOGIN_VALUES: LoginValues = {
email: DEMO_ACCOUNTS[0].email,
password: DEMO_ACCOUNTS[0].password,
remember: true,
};
export default function Login() {
const router = useRouter();
const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor);
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const notify = (type, msg) => toast(msg, { type });
const [ illustrationImage, setIllustrationImage ] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector(
(state) => state.auth,
);
const [showPassword, setShowPassword] = useState(false);
const [initialValues, setInitialValues] = useState<LoginValues>(DEFAULT_LOGIN_VALUES);
const notify = (type, msg) => toast(msg, { type });
const title = 'Gracey Corporate Stay Portal';
const registeredOrg = typeof router.query.registeredOrg === 'string' ? router.query.registeredOrg : '';
const registeredRole = typeof router.query.registeredRole === 'string' ? router.query.registeredRole : '';
const registeredTenants =
typeof router.query.registeredTenants === 'string'
? router.query.registeredTenants.split(' | ').filter(Boolean)
: [];
const [initialValues, setInitialValues] = React.useState({ email:'super_admin@flatlogic.com',
password: '946cafba',
remember: true })
const title = 'Gracey Corporate Stay Portal'
// Fetch Pexels image/video
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
// Fetch user data
useEffect(() => {
if (token) {
dispatch(findMe());
}
}, [token, dispatch]);
// Redirect to dashboard if user is logged in
useEffect(() => {
if (currentUser?.id) {
router.push('/command-center');
}
}, [currentUser?.id, router]);
// Show error message if there is one
useEffect(() => {
if (errorMessage){
notify('error', errorMessage)
if (errorMessage) {
notify('error', errorMessage);
}
}, [errorMessage]);
}, [errorMessage])
// Show notification if there is one
useEffect(() => {
if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification)
dispatch(resetAction());
}
}, [notifyState?.showNotification])
if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification);
dispatch(resetAction());
}
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
const handleSubmit = async (value) => {
const {remember, ...rest} = value
const handleSubmit = async (value: LoginValues) => {
const { remember, ...rest } = value;
await dispatch(loginUser(rest));
};
const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({
...prev,
email : target.innerText.trim(),
password: target.dataset.password ?? '',
}));
const chooseLogin = (email: string, password: string) => {
setInitialValues((prev) => ({
...prev,
email,
password,
}));
};
const imageBlock = (image) => (
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}>
<div className="flex justify-center w-full bg-blue-300/20">
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
by {image?.photographer} on Pexels</a>
const registeredMessage = useMemo(() => {
if (!registeredOrg) {
return null;
}
return (
<CardBox className='border border-amber-200 bg-amber-50/90 text-stone-900 shadow-none'>
<div className='flex items-start gap-3'>
<BaseIcon className='mt-1 text-amber-700' size={20} path={mdiInformation} />
<div className='min-w-0 space-y-2'>
<p className='font-semibold'>
Your {registeredRole || 'customer'} account was created for {registeredOrg}.
</p>
{registeredTenants.length ? (
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-amber-800'>Linked tenants</p>
<div className='mt-2 flex flex-wrap gap-2'>
{registeredTenants.map((tenantName) => (
<span
key={tenantName}
className='inline-flex items-center rounded-full border border-amber-200 bg-white/90 px-3 py-1 text-xs font-medium text-stone-900'
>
{tenantName}
</span>
))}
</div>
</div>
) : (
<p className='text-sm text-stone-700'>No linked tenant is shown for that organization yet.</p>
)}
<p className='text-xs text-stone-700'>
Admin and Super Admin accounts are provisioned internally, not through self-signup.
</p>
</div>
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
</div>
</CardBox>
);
}, [registeredOrg, registeredRole, registeredTenants]);
return (
<div style={contentPosition === 'background' ? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
} : {}}>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<SectionFullScreen bg='violet'>
<div className='min-h-screen w-full bg-gradient-to-br from-[#fbf6ee] via-[#f3eadb] to-[#dfc8a5] px-3 py-4 text-stone-900 sm:px-5 sm:py-6 lg:px-8 lg:py-8'>
<div className='mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-7xl flex-col overflow-hidden rounded-[2rem] border border-stone-200/90 bg-white/75 shadow-[0_30px_80px_rgba(68,44,23,0.12)] backdrop-blur md:grid md:grid-cols-[1.06fr_0.94fr]'>
<div className='relative overflow-hidden border-b border-stone-200/80 bg-[linear-gradient(180deg,rgba(255,250,243,0.98),rgba(243,233,216,0.98))] px-5 py-6 sm:px-8 sm:py-8 lg:px-12 lg:py-10 md:border-b-0 md:border-r'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(199,152,87,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.85),transparent_34%)]' />
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row justify-between'>
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="946cafba"
onClick={(e) => setLogin(e.target)}>super_admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>946cafba</code>{' / '}
to login as Super Admin</p>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="946cafba"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>946cafba</code>{' / '}
to login as Admin</p>
<p className='mb-2'>Use <code
className={`cursor-pointer ${textColor} `}
data-password="bc40952c93f4"
onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '}
<code className={`${textColor}`}>bc40952c93f4</code>{' / '}
to login as Concierge</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="bc40952c93f4"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>bc40952c93f4</code>{' / '}
to login as Customer</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
<div className='relative z-10 flex h-full flex-col justify-between gap-6'>
<div className='space-y-6 sm:space-y-8'>
<div className='space-y-4'>
<p className='text-[11px] font-semibold uppercase tracking-[0.34em] text-stone-500'>
Portal access
</p>
<div className='space-y-3'>
<h1 className='max-w-2xl text-3xl font-semibold leading-tight text-stone-950 sm:text-[2.6rem]'>
{title}
</h1>
<p className='max-w-2xl text-sm leading-7 text-stone-700 sm:text-base'>
Sign in to the private workspace used for booking intake, guest coordination, and billing
follow-through.
</p>
</div>
</CardBox>
<div className='flex flex-wrap gap-2'>
{ACCESS_CHIPS.map((chip) => (
<span
key={chip}
className='inline-flex items-center rounded-full border border-stone-200 bg-white/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-stone-600'
>
{chip}
</span>
))}
</div>
</div>
{registeredOrg ? (
<CardBox className='w-full border border-blue-200 bg-blue-50 text-blue-950 shadow-none md:w-3/5 lg:w-2/3 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
<div className='flex items-start gap-3'>
<BaseIcon className='mt-1 text-blue-700 dark:text-blue-200' size={20} path={mdiInformation} />
<div className='min-w-0 space-y-2'>
<p className='font-semibold'>
Your {registeredRole || 'customer'} account was created for {registeredOrg}.
</p>
{registeredTenants.length ? (
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-800 dark:text-blue-200'>Linked tenants</p>
<div className='mt-2 flex flex-wrap gap-2'>
{registeredTenants.map((tenantName) => (
<span
key={tenantName}
className='inline-flex items-center rounded-full border border-blue-200 bg-white/80 px-3 py-1 text-xs font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-100'
>
{tenantName}
</span>
))}
</div>
</div>
) : (
<p className='text-sm text-blue-800 dark:text-blue-100'>
No linked tenant is shown for that organization yet.
</p>
)}
<p className='text-xs text-blue-800 dark:text-blue-100'>
Admin and Super Admin accounts are provisioned internally, not through self-signup.
</p>
<div className='grid gap-3 rounded-[1.75rem] border border-amber-200/70 bg-white/60 p-4 sm:grid-cols-3 sm:p-5'>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Operator view
</p>
<p className='mt-2 text-sm text-stone-700'>Multi-tenant workspace</p>
</div>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Service posture
</p>
<p className='mt-2 text-sm text-stone-700'>Calm, selective, precise</p>
</div>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Execution model
</p>
<p className='mt-2 text-sm text-stone-700'>Request to reservation to billing</p>
</div>
</div>
<div className='space-y-4'>
<div className='flex items-center gap-3'>
<span className='h-px w-10 bg-amber-400/80' />
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-stone-600'>
Demo account access
</p>
</div>
<div className='grid gap-3 lg:grid-cols-2'>
{DEMO_ACCOUNTS.map((account) => {
const isSelected =
initialValues.email === account.email && initialValues.password === account.password;
return (
<button
key={account.email}
type='button'
onClick={() => chooseLogin(account.email, account.password)}
className={`rounded-[1.5rem] border p-4 text-left transition duration-150 hover:-translate-y-0.5 hover:shadow-lg ${account.accent} ${
isSelected ? 'ring-2 ring-amber-400/70' : 'ring-1 ring-transparent'
}`}
>
<div className='flex flex-wrap items-center justify-between gap-2'>
<p className='text-[11px] font-semibold uppercase tracking-[0.25em] text-current/70'>
{account.role}
</p>
<span className='rounded-full border border-current/15 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-current/70'>
Tap to fill
</span>
</div>
<p className='mt-3 break-all text-sm font-medium'>{account.email}</p>
<p className='mt-2 font-mono text-sm opacity-80'>{account.password}</p>
<p className='mt-3 text-xs leading-5 opacity-75'>{account.summary}</p>
</button>
);
})}
</div>
</div>
</div>
<div className='rounded-[1.5rem] border border-stone-200/90 bg-white/55 p-4 text-sm leading-6 text-stone-600'>
The public experience stays polished and restrained. Operational depth appears only where it
belongs: inside the authenticated workspace.
</div>
</div>
</div>
<div className='flex items-center justify-center bg-[linear-gradient(180deg,rgba(255,255,255,0.74),rgba(255,250,242,0.96))] px-4 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10'>
<div className='w-full max-w-xl space-y-5 sm:space-y-6'>
{registeredMessage}
<CardBox className='border border-stone-200/90 bg-white/95 text-stone-950 shadow-[0_22px_55px_rgba(60,38,20,0.08)] dark:!border-stone-200 dark:!bg-white dark:!text-stone-950'>
<div className='space-y-5 sm:space-y-6'>
<div className='space-y-3'>
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-stone-500'>Member sign in</p>
<h2 className='text-3xl font-semibold text-stone-950'>Welcome back</h2>
<p className='text-sm leading-6 text-stone-600'>
Enter your credentials to open the Gracey workspace.
</p>
<div className='flex flex-wrap gap-2 pt-1'>
{ACCESS_CHIPS.map((chip) => (
<span
key={chip}
className='inline-flex items-center rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-[11px] font-medium text-stone-600'
>
{chip}
</span>
))}
</div>
</div>
<Formik initialValues={initialValues} enableReinitialize onSubmit={handleSubmit}>
<Form>
<FormField label='Login' help='Please enter your login'>
<Field name='email' className={AUTH_INPUT_CLASS} />
</FormField>
<div className='relative'>
<FormField label='Password' help='Please enter your password'>
<Field name='password' type={showPassword ? 'text' : 'password'} className={AUTH_INPUT_CLASS} />
</FormField>
<button
type='button'
className='absolute bottom-8 right-0 flex items-center pr-3 text-stone-500 transition hover:text-stone-800'
onClick={() => setShowPassword((prev) => !prev)}
>
<BaseIcon size={20} path={showPassword ? mdiEyeOff : mdiEye} />
</button>
</div>
</CardBox>
) : null}
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<Formik
initialValues={initialValues}
enableReinitialize
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label='Login'
help='Please enter your login'>
<Field name='email' />
</FormField>
<div className='flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between'>
<FormCheckRadio type='checkbox' label='Remember'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<div className='relative'>
<FormField
label='Password'
help='Please enter your password'>
<Field name='password' type={showPassword ? 'text' : 'password'} />
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
className='text-gray-500 hover:text-gray-700'
size={20}
path={showPassword ? mdiEyeOff : mdiEye}
/>
</div>
</div>
<Link className={`${textColor} text-sm font-medium`} href='/forgot'>
Forgot password?
</Link>
</div>
<div className={'flex justify-between'}>
<FormCheckRadio type='checkbox' label='Remember'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<BaseDivider />
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
Forgot password?
</Link>
</div>
<BaseButtons className='w-full' type='justify-center' classAddon='mb-3' mb='mb-0'>
<BaseButton
className='w-full justify-center border-[#4d3929] bg-[#4d3929] text-white hover:bg-[#3d2d21] focus:ring-amber-300'
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='white'
disabled={isFetching}
/>
</BaseButtons>
<BaseDivider />
<BaseButtons>
<BaseButton
className={'w-full'}
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='info'
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>
</p>
</Form>
<p className='mt-4 text-center text-sm text-stone-600'>
Dont have an account yet?{' '}
<Link className={`${textColor} font-medium`} href='/register'>
New Account
</Link>
</p>
</Form>
</Formik>
</div>
</CardBox>
<div className='flex flex-col items-center justify-center gap-3 border-t border-stone-200 pt-1 text-center text-sm text-stone-600 sm:flex-row'>
<p>© 2026 {title}. © All rights reserved</p>
<div className='flex flex-wrap items-center justify-center gap-4'>
<Link className={`${textColor} text-sm`} href='/privacy-policy/'>
Privacy Policy
</Link>
<Link className={`${textColor} text-sm`} href='/terms-of-use/'>
Terms of Use
</Link>
</div>
</div>
</div>
</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>
<ToastContainer />
</div>
</SectionFullScreen>
<ToastContainer />
</>
);
}

View File

@ -1,20 +1,22 @@
import React from 'react';
import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import Link from 'next/link';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import { ToastContainer, toast } from 'react-toastify';
import Select from 'react-select';
import axios from 'axios';
import ConnectedEntityNotice from '../components/ConnectedEntityNotice';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
type PublicTenantOption = {
id: string;
@ -40,11 +42,23 @@ type OrganizationSelectOption = {
organization: PublicOrganizationOption;
};
type RegisterValues = {
email: string;
password: string;
confirm: string;
};
const TITLE = 'Gracey Corporate Stay Portal';
const REGISTER_CHIPS = ['Customer self-signup', 'Organization-linked access', 'Internal admin provisioning'];
const AUTH_INPUT_CLASS = '!bg-white !text-stone-900 placeholder:!text-stone-400 dark:!bg-white dark:!text-stone-900 dark:placeholder:!text-stone-400';
export default function Register() {
const [loading, setLoading] = React.useState(false);
const [organizations, setOrganizations] = React.useState<PublicOrganizationOption[]>([]);
const [selectedOrganization, setSelectedOrganization] = React.useState<OrganizationSelectOption | null>(null);
const router = useRouter();
const textColor = useAppSelector((state) => state.style.linkColor);
const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' });
React.useEffect(() => {
@ -69,7 +83,7 @@ export default function Register() {
const selectedOrganizationRecord = selectedOrganization?.organization || null;
const selectedTenants = selectedOrganizationRecord?.linkedTenants || [];
const handleSubmit = async (value) => {
const handleSubmit = async (value: RegisterValues) => {
if (!selectedOrganization) {
notify('error', 'Please select your organization before creating the account.');
return;
@ -114,87 +128,216 @@ export default function Register() {
<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">
<Formik
initialValues={{
email: '',
password: '',
confirm: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<label className="mb-2 block font-bold">Organization</label>
<SectionFullScreen bg='violet'>
<div className='min-h-screen w-full bg-gradient-to-br from-[#fbf6ee] via-[#f3eadb] to-[#dfc8a5] px-3 py-4 text-stone-900 sm:px-5 sm:py-6 lg:px-8 lg:py-8'>
<div className='mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-7xl flex-col overflow-hidden rounded-[2rem] border border-stone-200/90 bg-white/75 shadow-[0_30px_80px_rgba(68,44,23,0.12)] backdrop-blur md:grid md:grid-cols-[1.04fr_0.96fr]'>
<div className='relative overflow-hidden border-b border-stone-200/80 bg-[linear-gradient(180deg,rgba(255,250,243,0.98),rgba(243,233,216,0.98))] px-5 py-6 sm:px-8 sm:py-8 lg:px-12 lg:py-10 md:border-b-0 md:border-r'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(199,152,87,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.85),transparent_34%)]' />
<Select<OrganizationSelectOption, false>
classNames={{
control: () => 'px-1 mb-4 py-2',
}}
value={selectedOrganization}
onChange={(option) => setSelectedOrganization(option)}
options={options}
placeholder="Select organization..."
/>
{selectedOrganizationRecord ? (
<ConnectedEntityNotice
title="Account context"
description={
<div className="space-y-3">
<p>
Self-signup creates a <span className="font-semibold">Customer</span> account inside{' '}
<span className="font-semibold">{selectedOrganizationRecord.name}</span>.
</p>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-blue-800 dark:text-blue-200">
Linked tenant preview
</p>
{selectedTenants.length ? (
<div className="flex flex-wrap gap-2">
{selectedTenants.map((tenant) => (
<span
key={tenant.id}
className="inline-flex items-center rounded-full border border-blue-200 bg-white/80 px-3 py-1 text-xs font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-100"
>
{tenant.name || 'Unnamed tenant'}
</span>
))}
</div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-100">
No linked tenant is visible for this organization yet.
</p>
)}
</div>
<p className="text-xs text-blue-800 dark:text-blue-100">
Admin and Super Admin accounts are created internally by an existing privileged user.
<div className='relative z-10 flex h-full flex-col justify-between gap-6'>
<div className='space-y-6 sm:space-y-8'>
<div className='space-y-4'>
<p className='text-[11px] font-semibold uppercase tracking-[0.34em] text-stone-500'>
Customer enrollment
</p>
<div className='space-y-3'>
<h1 className='max-w-2xl text-3xl font-semibold leading-tight text-stone-950 sm:text-[2.55rem]'>
Create portal access with the right organization attached.
</h1>
<p className='max-w-2xl text-sm leading-7 text-stone-700 sm:text-base'>
Register a customer account for Gracey and connect it to the correct organization before the
first stay request is submitted.
</p>
</div>
}
/>
) : null}
<div className='flex flex-wrap gap-2'>
{REGISTER_CHIPS.map((chip) => (
<span
key={chip}
className='inline-flex items-center rounded-full border border-stone-200 bg-white/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-600'
>
{chip}
</span>
))}
</div>
</div>
<FormField label="Email" help="Please enter your email">
<Field type="email" name="email" />
</FormField>
<FormField label="Password" help="Please enter your password">
<Field type="password" name="password" />
</FormField>
<FormField label="Confirm Password" help="Please confirm your password">
<Field type="password" name="confirm" />
</FormField>
<div className='grid gap-3 rounded-[1.75rem] border border-amber-200/70 bg-white/60 p-4 sm:grid-cols-3 sm:p-5'>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Audience
</p>
<p className='mt-2 text-sm text-stone-700'>Customer-side account setup</p>
</div>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Organization scope
</p>
<p className='mt-2 text-sm text-stone-700'>Attach access to the right company</p>
</div>
<div>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Admin note
</p>
<p className='mt-2 text-sm text-stone-700'>Admin roles stay internally provisioned</p>
</div>
</div>
<BaseDivider />
<div className='rounded-[1.75rem] border border-stone-200/90 bg-white/55 p-5'>
<div className='flex items-center gap-3'>
<span className='h-px w-10 bg-amber-400/80' />
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-stone-600'>Enrollment notes</p>
</div>
<BaseButtons>
<BaseButton type="submit" label={loading ? 'Loading...' : 'Register'} color="info" />
<BaseButton href={'/login'} label={'Login'} color="info" />
</BaseButtons>
</Form>
</Formik>
</CardBox>
<div className='mt-4 space-y-4 text-sm leading-7 text-stone-700'>
<p>
Select the organization that should own the account. This keeps customer access tied to the
right operating context from the start.
</p>
<p>
Linked tenants, if available, become visible after account creation so the login experience
stays consistent with the workspace structure.
</p>
<p className='text-stone-600'>
Need admin or concierge access instead? Those roles should be created internally rather than
through public signup.
</p>
</div>
</div>
</div>
<div className='rounded-[1.5rem] border border-stone-200/90 bg-white/55 p-4 text-sm leading-6 text-stone-600'>
Gracey keeps public account creation narrow on purpose: clear audience, clear tenant context, and a
quieter path into the authenticated workspace.
</div>
</div>
</div>
<div className='flex items-center justify-center bg-[linear-gradient(180deg,rgba(255,255,255,0.74),rgba(255,250,242,0.96))] px-4 py-6 sm:px-8 sm:py-8 lg:px-10 lg:py-10'>
<div className='w-full max-w-xl space-y-5 sm:space-y-6'>
<CardBox className='border border-stone-200/90 bg-white/95 text-stone-950 shadow-[0_22px_55px_rgba(60,38,20,0.08)] dark:!border-stone-200 dark:!bg-white dark:!text-stone-950'>
<div className='space-y-5 sm:space-y-6'>
<div className='space-y-3'>
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-stone-500'>Customer access</p>
<h2 className='text-3xl font-semibold text-stone-950'>Create your account</h2>
<p className='text-sm leading-6 text-stone-600'>
Set your portal credentials and attach them to the right organization before your first stay
request begins.
</p>
<div className='flex flex-wrap gap-2 pt-1'>
{REGISTER_CHIPS.map((chip) => (
<span
key={chip}
className='inline-flex items-center rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-[11px] font-medium text-stone-600'
>
{chip}
</span>
))}
</div>
</div>
{selectedOrganizationRecord ? (
<div className='rounded-[1.25rem] border border-amber-200/80 bg-amber-50/80 p-4 text-sm text-stone-700'>
<p className='text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-800'>
Account context
</p>
<p className='mt-2 leading-6'>
This signup creates a <span className='font-semibold'>Customer</span> account inside{' '}
<span className='font-semibold'>{selectedOrganizationRecord.name}</span>.
</p>
</div>
) : null}
<Formik
initialValues={{
email: '',
password: '',
confirm: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<div className='mb-4'>
<label className='mb-2 block text-sm font-semibold text-stone-700'>Organization</label>
<Select<OrganizationSelectOption, false>
classNames={{
control: ({ isFocused }) =>
`min-h-[42px] rounded-lg border bg-white px-2 py-1 text-sm shadow-none ${
isFocused
? 'border-stone-900 ring-1 ring-stone-900'
: 'border-stone-300 hover:border-stone-400'
}`,
menu: () => 'overflow-hidden rounded-xl border border-stone-200 bg-white shadow-xl',
menuList: () => 'p-2',
option: ({ isFocused, isSelected }) =>
`cursor-pointer rounded-lg px-3 py-2 text-sm ${
isSelected
? 'bg-stone-900 text-white'
: isFocused
? 'bg-amber-50 text-stone-900'
: 'text-stone-700'
}`,
placeholder: () => 'text-stone-400',
singleValue: () => 'text-stone-900',
indicatorSeparator: () => 'hidden',
dropdownIndicator: () => 'text-stone-500',
}}
value={selectedOrganization}
onChange={(option) => setSelectedOrganization(option)}
options={options}
placeholder='Select organization...'
/>
</div>
<FormField label='Email' help='Please enter your email'>
<Field type='email' name='email' className={AUTH_INPUT_CLASS} />
</FormField>
<FormField label='Password' help='Please enter your password'>
<Field type='password' name='password' className={AUTH_INPUT_CLASS} />
</FormField>
<FormField label='Confirm Password' help='Please confirm your password'>
<Field type='password' name='confirm' className={AUTH_INPUT_CLASS} />
</FormField>
<BaseDivider />
<BaseButtons className='w-full' type='justify-center' classAddon='mb-3' mb='mb-0'>
<BaseButton
className='w-full justify-center border-[#4d3929] bg-[#4d3929] text-white hover:bg-[#3d2d21] focus:ring-amber-300'
type='submit'
label={loading ? 'Loading...' : 'Register'}
color='white'
disabled={loading}
/>
</BaseButtons>
<div className='flex flex-col items-center gap-3 text-center sm:flex-row sm:justify-between sm:text-left'>
<p className='text-sm text-stone-600'>Already have credentials?</p>
<Link className={`${textColor} text-sm font-medium`} href='/login'>
Return to Login
</Link>
</div>
</Form>
</Formik>
</div>
</CardBox>
<div className='flex flex-col items-center justify-center gap-3 border-t border-stone-200 pt-1 text-center text-sm text-stone-600 sm:flex-row'>
<p>© 2026 {TITLE}. © All rights reserved</p>
<div className='flex flex-wrap items-center justify-center gap-4'>
<Link className={`${textColor} text-sm`} href='/privacy-policy/'>
Privacy Policy
</Link>
<Link className={`${textColor} text-sm`} href='/terms-of-use/'>
Terms of Use
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</SectionFullScreen>
<ToastContainer />
</>
);