Autosave: 20260404-164039
This commit is contained in:
parent
d8c8294cc5
commit
77b3bcf3a6
@ -34,7 +34,7 @@ const FormField = ({ icons = [], ...props }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const controlClassName = [
|
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}`,
|
`${focusRing}`,
|
||||||
props.hasTextareaHeight ? 'h-24' : 'h-12',
|
props.hasTextareaHeight ? 'h-24' : 'h-12',
|
||||||
props.isBorderless ? 'border-0' : 'border',
|
props.isBorderless ? 'border-0' : 'border',
|
||||||
@ -54,10 +54,13 @@ const FormField = ({ icons = [], ...props }: Props) => {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className={`${elementWrapperClass}`}>
|
<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">
|
<div className="relative">
|
||||||
{cloneElement(child as ReactElement<{ className?: string }>, {
|
{cloneElement(child as ReactElement<{ className?: string }>, {
|
||||||
className: `${controlClassName} ${icons[index] ? 'pl-10' : ''}`,
|
className: `${controlClassName} ${childClassName} ${icons[index] ? 'pl-10' : ''}`.trim(),
|
||||||
})}
|
})}
|
||||||
{icons[index] && (
|
{icons[index] && (
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
@ -68,7 +71,8 @@ const FormField = ({ icons = [], ...props }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{props.help && (
|
{props.help && (
|
||||||
<div className='text-xs text-gray-500 dark:text-dark-600 mt-1'>{props.help}</div>
|
<div className='text-xs text-gray-500 dark:text-dark-600 mt-1'>{props.help}</div>
|
||||||
|
|||||||
@ -1,48 +1,166 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import 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 BaseButton from '../components/BaseButton';
|
||||||
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
|
import FormField from '../components/FormField';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
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 { 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() {
|
export default function Forgot() {
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false);
|
||||||
const router = useRouter();
|
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 {
|
try {
|
||||||
const { data: response } = await axios.post('/auth/send-password-reset-email', value);
|
await axios.post('/auth/send-password-reset-email', value);
|
||||||
setLoading(false)
|
notify('success', 'Please check your email for a password reset link.');
|
||||||
notify('success', 'Please check your email for verification link');
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await router.push('/login')
|
await router.push('/login');
|
||||||
}, 3000)
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false)
|
console.error('Password reset email request failed:', error);
|
||||||
console.log('error: ', error)
|
notify('error', 'Something was wrong. Try again.');
|
||||||
notify('error', 'Something was wrong. Try again')
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Forgot password')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='violet'>
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
<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%)]' />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 we’ll 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
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
@ -51,27 +169,49 @@ export default function Forgot() {
|
|||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<FormField label='Email' help='Please enter your email'>
|
<FormField label='Email' help='Please enter your email'>
|
||||||
<Field name='email' />
|
<Field type='email' name='email' className={AUTH_INPUT_CLASS} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<BaseDivider />
|
<BaseDivider />
|
||||||
|
|
||||||
<BaseButtons>
|
<BaseButtons className='w-full' type='justify-center' classAddon='mb-3' mb='mb-0'>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
className='w-full justify-center border-[#4d3929] bg-[#4d3929] text-white hover:bg-[#3d2d21] focus:ring-amber-300'
|
||||||
type='submit'
|
type='submit'
|
||||||
label={loading ? 'Loading...' : 'Submit' }
|
label={loading ? 'Loading...' : 'Send reset link'}
|
||||||
color='info'
|
color='white'
|
||||||
/>
|
disabled={loading}
|
||||||
<BaseButton
|
|
||||||
href={'/login'}
|
|
||||||
label={'Login'}
|
|
||||||
color='info'
|
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</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>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</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>
|
</SectionFullScreen>
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,235 +1,159 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
|
import { mdiEye, mdiEyeOff, mdiInformation } from '@mdi/js';
|
||||||
|
|
||||||
import BaseButton from '../components/BaseButton';
|
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 CardBox from '../components/CardBox';
|
||||||
import BaseIcon from "../components/BaseIcon";
|
import FormCheckRadio from '../components/FormCheckRadio';
|
||||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
import FormField from '../components/FormField';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
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 { getPageTitle } from '../config';
|
||||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import { useRouter } from 'next/router';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
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() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
|
||||||
const 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,
|
(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 registeredOrg = typeof router.query.registeredOrg === 'string' ? router.query.registeredOrg : '';
|
||||||
const registeredRole = typeof router.query.registeredRole === 'string' ? router.query.registeredRole : '';
|
const registeredRole = typeof router.query.registeredRole === 'string' ? router.query.registeredRole : '';
|
||||||
const registeredTenants =
|
const registeredTenants =
|
||||||
typeof router.query.registeredTenants === 'string'
|
typeof router.query.registeredTenants === 'string'
|
||||||
? router.query.registeredTenants.split(' | ').filter(Boolean)
|
? 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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
dispatch(findMe());
|
dispatch(findMe());
|
||||||
}
|
}
|
||||||
}, [token, dispatch]);
|
}, [token, dispatch]);
|
||||||
// Redirect to dashboard if user is logged in
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id) {
|
if (currentUser?.id) {
|
||||||
router.push('/command-center');
|
router.push('/command-center');
|
||||||
}
|
}
|
||||||
}, [currentUser?.id, router]);
|
}, [currentUser?.id, router]);
|
||||||
// Show error message if there is one
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
notify('error', errorMessage)
|
notify('error', errorMessage);
|
||||||
}
|
}
|
||||||
|
}, [errorMessage]);
|
||||||
|
|
||||||
}, [errorMessage])
|
|
||||||
// Show notification if there is one
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notifyState?.showNotification) {
|
if (notifyState?.showNotification) {
|
||||||
notify('success', notifyState?.textNotification)
|
notify('success', notifyState?.textNotification);
|
||||||
dispatch(resetAction());
|
dispatch(resetAction());
|
||||||
}
|
}
|
||||||
}, [notifyState?.showNotification])
|
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
||||||
|
|
||||||
const togglePasswordVisibility = () => {
|
const handleSubmit = async (value: LoginValues) => {
|
||||||
setShowPassword(!showPassword);
|
const { remember, ...rest } = value;
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (value) => {
|
|
||||||
const {remember, ...rest} = value
|
|
||||||
await dispatch(loginUser(rest));
|
await dispatch(loginUser(rest));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLogin = (target: HTMLElement) => {
|
const chooseLogin = (email: string, password: string) => {
|
||||||
setInitialValues(prev => ({
|
setInitialValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email : target.innerText.trim(),
|
email,
|
||||||
password: target.dataset.password ?? '',
|
password,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const registeredMessage = useMemo(() => {
|
||||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
if (!registeredOrg) {
|
||||||
style={{
|
return null;
|
||||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}>
|
|
||||||
<div className="flex justify-center w-full bg-blue-300/20">
|
|
||||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
|
||||||
by {image?.photographer} on Pexels</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video.user.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<CardBox className='border border-amber-200 bg-amber-50/90 text-stone-900 shadow-none'>
|
||||||
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>
|
|
||||||
|
|
||||||
<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'>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
{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'>
|
<div className='flex items-start gap-3'>
|
||||||
<BaseIcon className='mt-1 text-blue-700 dark:text-blue-200' size={20} path={mdiInformation} />
|
<BaseIcon className='mt-1 text-amber-700' size={20} path={mdiInformation} />
|
||||||
<div className='min-w-0 space-y-2'>
|
<div className='min-w-0 space-y-2'>
|
||||||
<p className='font-semibold'>
|
<p className='font-semibold'>
|
||||||
Your {registeredRole || 'customer'} account was created for {registeredOrg}.
|
Your {registeredRole || 'customer'} account was created for {registeredOrg}.
|
||||||
</p>
|
</p>
|
||||||
{registeredTenants.length ? (
|
{registeredTenants.length ? (
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-800 dark:text-blue-200'>Linked tenants</p>
|
<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'>
|
<div className='mt-2 flex flex-wrap gap-2'>
|
||||||
{registeredTenants.map((tenantName) => (
|
{registeredTenants.map((tenantName) => (
|
||||||
<span
|
<span
|
||||||
key={tenantName}
|
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'
|
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}
|
{tenantName}
|
||||||
</span>
|
</span>
|
||||||
@ -237,91 +161,218 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className='text-sm text-blue-800 dark:text-blue-100'>
|
<p className='text-sm text-stone-700'>No linked tenant is shown for that organization yet.</p>
|
||||||
No linked tenant is shown for that organization yet.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<p className='text-xs text-blue-800 dark:text-blue-100'>
|
<p className='text-xs text-stone-700'>
|
||||||
Admin and Super Admin accounts are provisioned internally, not through self-signup.
|
Admin and Super Admin accounts are provisioned internally, not through self-signup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
) : null}
|
);
|
||||||
|
}, [registeredOrg, registeredRole, registeredTenants]);
|
||||||
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
return (
|
||||||
<Formik
|
<>
|
||||||
initialValues={initialValues}
|
<Head>
|
||||||
enableReinitialize
|
<title>{getPageTitle('Login')}</title>
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
</Head>
|
||||||
|
|
||||||
|
<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%)]' />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<Form>
|
||||||
<FormField
|
<FormField label='Login' help='Please enter your login'>
|
||||||
label='Login'
|
<Field name='email' className={AUTH_INPUT_CLASS} />
|
||||||
help='Please enter your login'>
|
|
||||||
<Field name='email' />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<FormField
|
<FormField label='Password' help='Please enter your password'>
|
||||||
label='Password'
|
<Field name='password' type={showPassword ? 'text' : 'password'} className={AUTH_INPUT_CLASS} />
|
||||||
help='Please enter your password'>
|
|
||||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<div
|
<button
|
||||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
type='button'
|
||||||
onClick={togglePasswordVisibility}
|
className='absolute bottom-8 right-0 flex items-center pr-3 text-stone-500 transition hover:text-stone-800'
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<BaseIcon
|
<BaseIcon size={20} path={showPassword ? mdiEyeOff : mdiEye} />
|
||||||
className='text-gray-500 hover:text-gray-700'
|
</button>
|
||||||
size={20}
|
|
||||||
path={showPassword ? mdiEyeOff : mdiEye}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex justify-between'}>
|
<div className='flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<FormCheckRadio type='checkbox' label='Remember'>
|
<FormCheckRadio type='checkbox' label='Remember'>
|
||||||
<Field type='checkbox' name='remember' />
|
<Field type='checkbox' name='remember' />
|
||||||
</FormCheckRadio>
|
</FormCheckRadio>
|
||||||
|
|
||||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
<Link className={`${textColor} text-sm font-medium`} href='/forgot'>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BaseDivider />
|
<BaseDivider />
|
||||||
|
|
||||||
<BaseButtons>
|
<BaseButtons className='w-full' type='justify-center' classAddon='mb-3' mb='mb-0'>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'w-full'}
|
className='w-full justify-center border-[#4d3929] bg-[#4d3929] text-white hover:bg-[#3d2d21] focus:ring-amber-300'
|
||||||
type='submit'
|
type='submit'
|
||||||
label={isFetching ? 'Loading...' : 'Login'}
|
label={isFetching ? 'Loading...' : 'Login'}
|
||||||
color='info'
|
color='white'
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
<br />
|
|
||||||
<p className={'text-center'}>
|
<p className='mt-4 text-center text-sm text-stone-600'>
|
||||||
Don’t have an account yet?{' '}
|
Don’t have an account yet?{' '}
|
||||||
<Link className={`${textColor}`} href={'/register'}>
|
<Link className={`${textColor} font-medium`} href='/register'>
|
||||||
New Account
|
New Account
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</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 />
|
<ToastContainer />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import Link from 'next/link';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import axios from 'axios';
|
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 = {
|
type PublicTenantOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -40,11 +42,23 @@ type OrganizationSelectOption = {
|
|||||||
organization: PublicOrganizationOption;
|
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() {
|
export default function Register() {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [organizations, setOrganizations] = React.useState<PublicOrganizationOption[]>([]);
|
const [organizations, setOrganizations] = React.useState<PublicOrganizationOption[]>([]);
|
||||||
const [selectedOrganization, setSelectedOrganization] = React.useState<OrganizationSelectOption | null>(null);
|
const [selectedOrganization, setSelectedOrganization] = React.useState<OrganizationSelectOption | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' });
|
const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' });
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -69,7 +83,7 @@ export default function Register() {
|
|||||||
const selectedOrganizationRecord = selectedOrganization?.organization || null;
|
const selectedOrganizationRecord = selectedOrganization?.organization || null;
|
||||||
const selectedTenants = selectedOrganizationRecord?.linkedTenants || [];
|
const selectedTenants = selectedOrganizationRecord?.linkedTenants || [];
|
||||||
|
|
||||||
const handleSubmit = async (value) => {
|
const handleSubmit = async (value: RegisterValues) => {
|
||||||
if (!selectedOrganization) {
|
if (!selectedOrganization) {
|
||||||
notify('error', 'Please select your organization before creating the account.');
|
notify('error', 'Please select your organization before creating the account.');
|
||||||
return;
|
return;
|
||||||
@ -114,8 +128,125 @@ export default function Register() {
|
|||||||
<title>{getPageTitle('Register')}</title>
|
<title>{getPageTitle('Register')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg="violet">
|
<SectionFullScreen bg='violet'>
|
||||||
<CardBox className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12">
|
<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%)]' />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
@ -125,76 +256,88 @@ export default function Register() {
|
|||||||
onSubmit={(values) => handleSubmit(values)}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<label className="mb-2 block font-bold">Organization</label>
|
<div className='mb-4'>
|
||||||
|
<label className='mb-2 block text-sm font-semibold text-stone-700'>Organization</label>
|
||||||
<Select<OrganizationSelectOption, false>
|
<Select<OrganizationSelectOption, false>
|
||||||
classNames={{
|
classNames={{
|
||||||
control: () => 'px-1 mb-4 py-2',
|
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}
|
value={selectedOrganization}
|
||||||
onChange={(option) => setSelectedOrganization(option)}
|
onChange={(option) => setSelectedOrganization(option)}
|
||||||
options={options}
|
options={options}
|
||||||
placeholder="Select organization..."
|
placeholder='Select organization...'
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedOrganizationRecord ? (
|
<FormField label='Email' help='Please enter your email'>
|
||||||
<ConnectedEntityNotice
|
<Field type='email' name='email' className={AUTH_INPUT_CLASS} />
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FormField label="Email" help="Please enter your email">
|
|
||||||
<Field type="email" name="email" />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Password" help="Please enter your password">
|
<FormField label='Password' help='Please enter your password'>
|
||||||
<Field type="password" name="password" />
|
<Field type='password' name='password' className={AUTH_INPUT_CLASS} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Confirm Password" help="Please confirm your password">
|
<FormField label='Confirm Password' help='Please confirm your password'>
|
||||||
<Field type="password" name="confirm" />
|
<Field type='password' name='confirm' className={AUTH_INPUT_CLASS} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<BaseDivider />
|
<BaseDivider />
|
||||||
|
|
||||||
<BaseButtons>
|
<BaseButtons className='w-full' type='justify-center' classAddon='mb-3' mb='mb-0'>
|
||||||
<BaseButton type="submit" label={loading ? 'Loading...' : 'Register'} color="info" />
|
<BaseButton
|
||||||
<BaseButton href={'/login'} label={'Login'} color="info" />
|
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>
|
</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>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</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>
|
</SectionFullScreen>
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user