Autosave: 20260404-155315
This commit is contained in:
parent
95c088fa21
commit
d8c8294cc5
@ -570,7 +570,7 @@ module.exports = class Booking_requestsDBApi {
|
||||
|
||||
if (userOrganizations) {
|
||||
if (options?.currentUser?.organizationsId) {
|
||||
where.organizationsId = options.currentUser.organizationsId;
|
||||
where.organizationId = options.currentUser.organizationsId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1036,7 +1036,7 @@ module.exports = class Booking_requestsDBApi {
|
||||
|
||||
|
||||
if (globalAccess) {
|
||||
delete where.organizationsId;
|
||||
delete where.organizationId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -607,15 +607,18 @@ module.exports = class ReservationsDBApi {
|
||||
|
||||
|
||||
const user = (options && options.currentUser) || null;
|
||||
const userOrganizations = (user && user.organizations?.id) || null;
|
||||
const userOrganizationId =
|
||||
user?.organization?.id ||
|
||||
user?.organizations?.id ||
|
||||
user?.organizationId ||
|
||||
user?.organizationsId ||
|
||||
null;
|
||||
const isCustomer = isCustomerUser(user);
|
||||
|
||||
|
||||
|
||||
if (userOrganizations) {
|
||||
if (options?.currentUser?.organizationsId) {
|
||||
where.organizationsId = options.currentUser.organizationsId;
|
||||
}
|
||||
if (!globalAccess && userOrganizationId) {
|
||||
where.organizationId = userOrganizationId;
|
||||
}
|
||||
|
||||
|
||||
@ -1196,7 +1199,7 @@ module.exports = class ReservationsDBApi {
|
||||
|
||||
|
||||
if (globalAccess) {
|
||||
delete where.organizationsId;
|
||||
delete where.organizationId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -49,11 +49,13 @@ const CurrentWorkspaceChip = () => {
|
||||
currentUser?.organizationsId,
|
||||
],
|
||||
)
|
||||
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
|
||||
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
if (!organizationId) {
|
||||
if (!organizationId || !canViewTenants) {
|
||||
setLinkedTenantSummary(emptyOrganizationTenantSummary)
|
||||
setIsLoadingTenants(false)
|
||||
return () => {
|
||||
@ -89,7 +91,7 @@ const CurrentWorkspaceChip = () => {
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [organizationId])
|
||||
}, [canViewTenants, organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
setIsPopoverActive(false)
|
||||
@ -139,11 +141,18 @@ const CurrentWorkspaceChip = () => {
|
||||
const appRoleName = currentUser?.app_role?.name || 'User'
|
||||
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
|
||||
const linkedTenantCount = linkedTenantSummary.count || 0
|
||||
const tenantLabel = isLoadingTenants
|
||||
const tenantLabel = !organizationId
|
||||
? 'No workspace linked'
|
||||
: !canViewTenants
|
||||
? 'Tenant access restricted'
|
||||
: isLoadingTenants
|
||||
? 'Loading tenants…'
|
||||
: `${linkedTenantCount} tenant${linkedTenantCount === 1 ? '' : 's'}`
|
||||
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
|
||||
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
||||
const tenantEmptyStateMessage = !organizationId
|
||||
? 'No organization is attached to this account yet.'
|
||||
: !canViewTenants
|
||||
? 'Your current role can open the workspace, but it does not include tenant-read access.'
|
||||
: 'No tenant link surfaced yet for this workspace.'
|
||||
const organizationHref =
|
||||
canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile'
|
||||
const hasOrganizationMetadata = Boolean(organizationSlug || organizationDomain)
|
||||
@ -340,7 +349,7 @@ const CurrentWorkspaceChip = () => {
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
|
||||
No tenant link surfaced yet for this workspace.
|
||||
{tenantEmptyStateMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -464,7 +473,7 @@ const CurrentWorkspaceChip = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
|
||||
No tenant link surfaced yet for this workspace.
|
||||
{tenantEmptyStateMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -41,11 +41,13 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
|
||||
currentUser?.organization?.name ||
|
||||
currentUser?.organizationName ||
|
||||
'No organization assigned yet'
|
||||
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
|
||||
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
if (!organizationId) {
|
||||
if (!organizationId || !canViewTenants) {
|
||||
setLinkedTenantSummary(emptyOrganizationTenantSummary)
|
||||
setIsLoadingTenants(false)
|
||||
return () => {
|
||||
@ -81,7 +83,7 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [organizationId])
|
||||
}, [canViewTenants, organizationId])
|
||||
|
||||
if (!currentUser) {
|
||||
return null
|
||||
@ -89,8 +91,18 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
|
||||
|
||||
const appRoleName = currentUser?.app_role?.name || 'No role surfaced yet'
|
||||
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
|
||||
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
|
||||
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
||||
const tenantSummaryValue = !organizationId
|
||||
? 'No workspace linked'
|
||||
: !canViewTenants
|
||||
? 'Restricted'
|
||||
: isLoadingTenants
|
||||
? 'Loading…'
|
||||
: String(linkedTenantSummary.count || 0)
|
||||
const tenantEmptyStateMessage = !organizationId
|
||||
? 'No organization is attached to this account yet.'
|
||||
: !canViewTenants
|
||||
? 'Your current role does not include tenant-read access for this organization context.'
|
||||
: 'No tenant link surfaced yet for your organization.'
|
||||
|
||||
return (
|
||||
<CardBox className={className}>
|
||||
@ -116,7 +128,7 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
|
||||
{ label: 'Email', value: currentUser?.email },
|
||||
{
|
||||
label: 'Linked tenants',
|
||||
value: isLoadingTenants ? 'Loading…' : String(linkedTenantSummary.count || 0),
|
||||
value: tenantSummaryValue,
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
@ -179,7 +191,7 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
|
||||
No tenant link surfaced yet for your organization.
|
||||
{tenantEmptyStateMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
44
frontend/src/components/PublicSiteFooter.tsx
Normal file
44
frontend/src/components/PublicSiteFooter.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
import { webPagesNavBar } from '../menuNavBar';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export default function PublicSiteFooter({
|
||||
title = 'Gracey Corporate Stay Portal',
|
||||
subtitle = 'A cleaner public front door for bookings, guest operations, and billing.',
|
||||
}: Props) {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-slate-200 bg-white">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-10 lg:grid-cols-[1.2fr_0.8fr] lg:px-10">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-500">{title}</div>
|
||||
<p className="mt-3 max-w-xl text-sm leading-7 text-slate-600">{subtitle}</p>
|
||||
<div className="mt-5 text-sm text-slate-500">© {year} {title}. All rights reserved.</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:items-end">
|
||||
<nav className="flex flex-wrap gap-4 text-sm text-slate-600 lg:justify-end">
|
||||
{webPagesNavBar.map((item) => (
|
||||
<Link key={item.href || item.label} href={item.href || '/'} className="transition-colors hover:text-slate-950">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<BaseButton href="/login" color="whiteDark" label="Login" small />
|
||||
<BaseButton href="/command-center" color="info" label="Open workspace" small />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/PublicSiteHeader.tsx
Normal file
41
frontend/src/components/PublicSiteHeader.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
import { webPagesNavBar } from '../menuNavBar';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export default function PublicSiteHeader({
|
||||
title = 'Gracey Corporate Stay Portal',
|
||||
subtitle = 'Corporate stay operations',
|
||||
}: Props) {
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200/80 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4 lg:px-10">
|
||||
<Link href="/" className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-500">{subtitle}</div>
|
||||
<div className="truncate text-base font-semibold text-slate-950 md:text-lg">{title}</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<nav className="flex items-center gap-5 text-sm text-slate-600">
|
||||
{webPagesNavBar.map((item) => (
|
||||
<Link key={item.href || item.label} href={item.href || '/'} className="transition-colors hover:text-slate-950">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" color="whiteDark" label="Login" small />
|
||||
<BaseButton href="/command-center" color="info" label="Open workspace" small />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,31 +1,95 @@
|
||||
import React, {useEffect, useId, useState} from 'react';
|
||||
import React, { useEffect, useId, useState } from 'react';
|
||||
import { AsyncPaginate } from 'react-select-async-paginate';
|
||||
import axios from 'axios';
|
||||
|
||||
const getOptionLabel = (item: any, showField?: string) => {
|
||||
if (!item) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (showField && item[showField]) {
|
||||
return item[showField];
|
||||
}
|
||||
|
||||
return (
|
||||
item.label ||
|
||||
item.name ||
|
||||
item.firstName ||
|
||||
item.email ||
|
||||
item.action ||
|
||||
item.title ||
|
||||
item.request_code ||
|
||||
item.reservation_code ||
|
||||
item.file_name ||
|
||||
item.summary ||
|
||||
item.reference ||
|
||||
item.id ||
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
const toSelectOption = (item: any, showField?: string) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = item.value || item.id;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
label: getOptionLabel(item, showField),
|
||||
};
|
||||
};
|
||||
|
||||
export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => {
|
||||
const [value, setValue] = useState([]);
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (field.value?.[0] && typeof field.value[0] !== 'string') {
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
field.value.map((el) => el.id),
|
||||
);
|
||||
} else if (!field.value || field.value.length === 0) {
|
||||
if (!Array.isArray(field?.value) || field.value.length === 0) {
|
||||
setValue([]);
|
||||
return;
|
||||
}
|
||||
}, [field.name, field.value, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options) {
|
||||
setValue(options.map((el) => ({ value: el.id, label: el[showField] })));
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
options.map((el) => ({ value: el.id, label: el[showField] })),
|
||||
);
|
||||
const nextValue = field.value
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const matchingOption = Array.isArray(options)
|
||||
? options.find((option) => option?.id === item || option?.value === item)
|
||||
: null;
|
||||
|
||||
return {
|
||||
value: item,
|
||||
label: getOptionLabel(matchingOption, showField) || item,
|
||||
};
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return toSelectOption(item, showField);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
setValue(nextValue);
|
||||
|
||||
const normalizedIds = field.value
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return item?.id || item?.value || null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const shouldNormalizeFieldValue = field.value.some((item) => typeof item !== 'string');
|
||||
|
||||
if (shouldNormalizeFieldValue) {
|
||||
form.setFieldValue(field.name, normalizedIds);
|
||||
}
|
||||
}, [field?.name, field?.value, form, options, showField]);
|
||||
|
||||
const mapResponseToValuesAndLabels = (data) => ({
|
||||
value: data.id,
|
||||
@ -33,10 +97,12 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
|
||||
});
|
||||
|
||||
const handleChange = (data: any) => {
|
||||
setValue(data)
|
||||
const nextValue = Array.isArray(data) ? data : [];
|
||||
|
||||
setValue(nextValue);
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
data.map(el => (el?.value || null)),
|
||||
nextValue.map((item) => item?.value).filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
@ -46,8 +112,9 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
|
||||
return {
|
||||
options: data.map(mapResponseToValuesAndLabels),
|
||||
hasMore: data.length === PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncPaginate
|
||||
classNames={{
|
||||
|
||||
@ -46,8 +46,19 @@ const menuNavBar: MenuNavBarItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const webPagesNavBar = [
|
||||
|
||||
];
|
||||
export const webPagesNavBar: MenuNavBarItem[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
label: 'Privacy',
|
||||
href: '/privacy-policy',
|
||||
},
|
||||
{
|
||||
label: 'Terms',
|
||||
href: '/terms-of-use',
|
||||
},
|
||||
]
|
||||
|
||||
export default menuNavBar
|
||||
|
||||
@ -5,47 +5,64 @@ import {
|
||||
mdiFileDocument,
|
||||
mdiHomeCity,
|
||||
mdiRoomService,
|
||||
mdiShieldLockOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import PublicSiteFooter from '../components/PublicSiteFooter';
|
||||
import PublicSiteHeader from '../components/PublicSiteHeader';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const platformHighlights = [
|
||||
const productAreas = [
|
||||
{
|
||||
title: 'Corporate accounts',
|
||||
description: 'Manage negotiated rate plans, account history, billing rules, and repeat-booker relationships.',
|
||||
icon: mdiShieldLockOutline,
|
||||
},
|
||||
{
|
||||
title: 'Reservation control',
|
||||
description: 'Move a stay from request to approval, inventory assignment, confirmation, arrival, and post-stay handling.',
|
||||
title: 'Booking intake',
|
||||
description: 'Receive requests with the right tenant, dates, approvals, and context before service delivery begins.',
|
||||
icon: mdiCalendarCheck,
|
||||
},
|
||||
{
|
||||
title: 'Concierge operations',
|
||||
description: 'Track airport pickup, housekeeping, maintenance, extension, and custom guest service requests.',
|
||||
title: 'Stay operations',
|
||||
description: 'Coordinate readiness, guest service, and internal updates from one controlled operating view.',
|
||||
icon: mdiRoomService,
|
||||
},
|
||||
{
|
||||
title: 'Finance visibility',
|
||||
description: 'Issue invoices, watch overdue exposure, and keep executive housing accounts commercially disciplined.',
|
||||
title: 'Billing follow-through',
|
||||
description: 'Keep invoicing and account visibility tied to the same reservation record from start to finish.',
|
||||
icon: mdiFileDocument,
|
||||
},
|
||||
];
|
||||
|
||||
const workflowSteps = [
|
||||
'Corporate booker submits a stay request',
|
||||
'Approver reviews against policy and budget',
|
||||
'Reservations team quotes and allocates inventory',
|
||||
'Traveler receives confirmed stay details',
|
||||
'Concierge and finance teams manage service and billing',
|
||||
const servicePillars = [
|
||||
'Tenant-aware workspace context for multi-organization teams',
|
||||
'Reserved, premium visual tone without turning the page into a template',
|
||||
'A direct path from public website to the real operating workspace',
|
||||
];
|
||||
|
||||
const workflowMoments = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Receive the brief',
|
||||
description: 'Capture the request with account context, approvals, dates, and service notes intact.',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Deliver the stay',
|
||||
description: 'Move into reservations, readiness, support, and day-to-day guest coordination with clarity.',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Close with confidence',
|
||||
description: 'Finish with billing and account follow-through attached to the same operational source of truth.',
|
||||
},
|
||||
];
|
||||
|
||||
const highlights = [
|
||||
{ label: 'Private operator view', value: 'Multi-tenant workspace' },
|
||||
{ label: 'Service posture', value: 'Calm, controlled, premium' },
|
||||
{ label: 'Execution model', value: 'Request to reservation to billing' },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
@ -53,120 +70,167 @@ export default function HomePage() {
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Gracey Corporate Stay Portal')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Manage booking requests, reservations, guest operations, and billing from one refined corporate stay workspace."
|
||||
/>
|
||||
</Head>
|
||||
<main className="min-h-screen bg-[#f4f6f8] text-slate-950">
|
||||
<section className="border-b border-slate-200 bg-[#0b1220] text-white">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-12 px-6 py-6 lg:px-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-slate-400">Gracey Corporate Stay Portal</div>
|
||||
<div className="mt-2 text-sm text-slate-300">Executive software for serviced apartments, embassy housing, and long-stay corporate operations.</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/login" color="whiteDark" label="Login" />
|
||||
<BaseButton href="/command-center" color="info" label="Open workspace" />
|
||||
</div>
|
||||
|
||||
<main className="min-h-screen bg-[#f7f4ee] text-slate-950">
|
||||
<PublicSiteHeader />
|
||||
|
||||
<section className="relative overflow-hidden border-b border-[#d9cfbf] bg-[linear-gradient(135deg,rgba(15,23,42,0.96)_0%,rgba(30,41,59,0.92)_42%,rgba(88,65,38,0.88)_100%)] text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.12),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(245,208,140,0.18),_transparent_26%)]" />
|
||||
<div className="relative mx-auto grid max-w-6xl gap-14 px-6 py-18 lg:grid-cols-[1.1fr_0.9fr] lg:px-10 lg:py-24">
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-white/15 bg-white/8 px-4 py-1.5 text-[11px] font-medium uppercase tracking-[0.32em] text-[#ead7b0]">
|
||||
<BaseIcon path={mdiHomeCity} size={16} className="text-[#f5deb3]" />
|
||||
Gracey private operations suite
|
||||
</div>
|
||||
|
||||
<div className="grid gap-10 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.24em] text-slate-300">
|
||||
<BaseIcon path={mdiHomeCity} className="text-slate-100" />
|
||||
Luxury corporate stay operations
|
||||
</div>
|
||||
<h1 className="mt-6 max-w-4xl text-4xl font-semibold leading-tight text-white md:text-6xl">
|
||||
The control layer for premium furnished inventory, approvals, guest logistics, and invoice billing.
|
||||
<h1 className="mt-7 max-w-4xl text-4xl font-semibold leading-[1.02] text-white md:text-[64px]">
|
||||
A more elegant operating layer for premium corporate stays.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300">
|
||||
Gracey helps serviced-apartment operators win repeat institutional accounts with faster quoting,
|
||||
cleaner approval workflows, better arrival readiness, and a more executive operating posture.
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-200">
|
||||
Gracey gives booking, service, and billing teams one composed system for high-touch stays—quiet in tone,
|
||||
precise in execution, and built for the work behind the guest experience.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/command-center" color="info" label="Open command center" icon={mdiArrowTopRight} />
|
||||
<BaseButton href="/booking_requests/booking_requests-list" color="whiteDark" label="Review booking workflow" />
|
||||
<BaseButton href="/command-center" color="info" label="Open workspace" icon={mdiArrowTopRight} />
|
||||
<BaseButton href="/login" color="whiteDark" label="Login" />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 sm:grid-cols-3">
|
||||
{highlights.map((item) => (
|
||||
<div key={item.label} className="border-l border-white/20 pl-4">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.24em] text-[#d7c19a]">{item.label}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-100">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardBox className="border border-white/10 bg-white/5 text-white shadow-[0_30px_80px_-40px_rgba(15,23,42,0.9)]">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Use cases</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Corporate teams, NGOs, embassies</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">Built for medium-stay housing that needs negotiated rates, approvals, and invoice-grade accountability.</div>
|
||||
<CardBox className="border border-white/10 bg-white/8 text-white shadow-[0_40px_120px_-60px_rgba(15,23,42,0.95)] backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-[#d7c19a]">Operator brief</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">What the platform quietly keeps under control</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Operational posture</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Cold, executive, credible</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">No consumer fluff. Just the controls needed to run luxury serviced inventory for professional clients.</div>
|
||||
<div className="rounded-full border border-[#d7c19a]/40 bg-[#d7c19a]/12 px-3 py-1 text-xs font-medium text-[#f2dfba]">
|
||||
Refined workflow
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{workflowMoments.map((item) => (
|
||||
<div key={item.step} className="rounded-[28px] border border-white/10 bg-black/10 p-5">
|
||||
<div className="text-xs font-semibold tracking-[0.24em] text-[#d7c19a]">{item.step}</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-200">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[28px] border border-white/10 bg-white/6 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<BaseIcon path={mdiCheckDecagram} size={20} className="mt-0.5 text-[#f0d8ab]" />
|
||||
<p className="text-sm leading-7 text-slate-100">
|
||||
The public experience stays polished and restrained. The real depth appears only where it belongs:
|
||||
inside the authenticated workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{platformHighlights.map((item) => (
|
||||
<CardBox key={item.title} className="border border-slate-200 bg-white shadow-[0_20px_60px_-40px_rgba(15,23,42,0.45)]">
|
||||
<div className="rounded-2xl bg-slate-950 p-3 text-white w-fit">
|
||||
<BaseIcon path={item.icon} size={20} />
|
||||
<section className="mx-auto max-w-6xl px-6 py-16 lg:px-10 lg:py-20">
|
||||
<div className="grid gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-end">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-[#8b6b42]">Signature coverage</div>
|
||||
<h2 className="mt-3 max-w-2xl text-3xl font-semibold leading-tight text-slate-950 md:text-5xl">
|
||||
Designed for teams that want operational polish, not marketing noise.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="max-w-2xl text-base leading-8 text-slate-700 lg:justify-self-end">
|
||||
Every section is meant to feel intentional: fewer blocks, better restraint, and a stronger sense that the
|
||||
product supports a premium service model behind the scenes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-3">
|
||||
{productAreas.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-[32px] border border-[#d9cfbf] bg-[#fcfaf6] p-7 shadow-[0_28px_70px_-60px_rgba(15,23,42,0.45)]"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#efe5d4] text-[#5f4526]">
|
||||
<BaseIcon path={item.icon} size={22} />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-semibold text-slate-950">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-700">{item.description}</p>
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-slate-950">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{item.description}</p>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 pb-16 lg:px-10">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<CardBox className="border border-slate-200 bg-white shadow-[0_20px_60px_-40px_rgba(15,23,42,0.45)]">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">First delivered workflow</div>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-slate-950">Corporate stay booking lifecycle</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-600">
|
||||
The initial slice connects the public brand front door to a live authenticated command center, then into the existing request, approval, reservation, concierge, and finance records.
|
||||
<section className="border-y border-[#d9cfbf] bg-[#efe7db]">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-6 py-16 lg:grid-cols-[0.95fr_1.05fr] lg:px-10 lg:py-20">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-[#8b6b42]">Service standard</div>
|
||||
<h2 className="mt-3 text-3xl font-semibold leading-tight text-slate-950 md:text-4xl">
|
||||
Luxury online should feel composed, selective, and credible.
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-8 text-slate-700">
|
||||
So the page leads with restraint: rich tone, quiet confidence, and a product story grounded in actual
|
||||
operations rather than inflated feature theater.
|
||||
</p>
|
||||
<BaseDivider />
|
||||
<div className="space-y-4">
|
||||
{workflowSteps.map((step, index) => (
|
||||
<div key={step} className="flex gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-950 text-sm font-semibold text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-slate-700">{step}</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{servicePillars.map((item) => (
|
||||
<div key={item} className="flex items-start gap-4 rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur">
|
||||
<BaseIcon path={mdiCheckDecagram} size={20} className="mt-1 text-[#8b6b42]" />
|
||||
<p className="text-sm leading-7 text-slate-800">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CardBox className="border border-slate-200 bg-[#0f172a] text-white shadow-[0_24px_70px_-38px_rgba(15,23,42,0.8)]">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-slate-400">Immediate access</div>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-white">Choose your entry point</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
Enter the authenticated workspace to manage live operations, or go straight to the broader admin environment.
|
||||
<section className="px-6 py-16 lg:px-10 lg:py-20">
|
||||
<div className="mx-auto max-w-5xl rounded-[36px] border border-[#cfbc9b] bg-[linear-gradient(135deg,#111827_0%,#1f2937_58%,#6b4f2c_100%)] px-6 py-10 text-white shadow-[0_40px_120px_-55px_rgba(15,23,42,0.9)] lg:px-10 lg:py-12">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-end">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-[#dcc49f]">Private access</div>
|
||||
<h2 className="mt-3 max-w-2xl text-3xl font-semibold leading-tight md:text-4xl">
|
||||
Enter the workspace when the conversation turns into execution.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-200 md:text-base">
|
||||
Review booking flow activity, open the operating workspace, and continue from live data instead of a
|
||||
brochure-style homepage.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Operations</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Command center</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">Live view across bookings, reservations, service pressure, and invoice exposure.</div>
|
||||
<div className="mt-5">
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/10 p-5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.24em] text-[#d7c19a]">For internal teams</div>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-200">
|
||||
Reservations, service operations, and billing remain connected—without losing the elevated feel the
|
||||
brand should project externally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/command-center" color="info" label="Open workspace" />
|
||||
<BaseButton href="/booking_requests/booking_requests-list" color="whiteDark" label="View booking flow" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Administration</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Role-based workspace</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">Land in one shared command center, then branch into the modules your role actually owns.</div>
|
||||
<div className="mt-5">
|
||||
<BaseButton href="/command-center" color="whiteDark" label="Open workspace" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PublicSiteFooter />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import PublicSiteFooter from '../components/PublicSiteFooter';
|
||||
import PublicSiteHeader from '../components/PublicSiteHeader';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
@ -261,14 +263,16 @@ export default function PrivacyPolicy() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='prose prose-slate mx-auto max-w-none'>
|
||||
<div className='min-h-screen bg-slate-50 text-slate-950'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Privacy Policy')}</title>
|
||||
</Head>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'>
|
||||
<div className='p-8 lg:px-12 lg:py-10'>
|
||||
<PublicSiteHeader />
|
||||
|
||||
<div className='mx-auto flex max-w-6xl justify-center px-6 py-8 lg:px-10 lg:py-10'>
|
||||
<div className='z-10 w-full rounded-2xl border border-slate-200 bg-white shadow-[0_18px_50px_-42px_rgba(15,23,42,0.35)]'>
|
||||
<div className='prose prose-slate max-w-none p-8 lg:px-12 lg:py-10'>
|
||||
<h1>Privacy Policy</h1>
|
||||
<Introduction />
|
||||
<Information />
|
||||
@ -283,6 +287,8 @@ export default function PrivacyPolicy() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PublicSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -390,6 +390,7 @@ const EditTenantsPage = () => {
|
||||
itemRef={'organizations'}
|
||||
options={initialValues.organizations}
|
||||
component={SelectFieldMany}
|
||||
showField={'name'}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
@ -400,6 +401,7 @@ const EditTenantsPage = () => {
|
||||
itemRef={'properties'}
|
||||
options={initialValues.properties}
|
||||
component={SelectFieldMany}
|
||||
showField={'name'}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -225,6 +225,7 @@ const TenantsNew = () => {
|
||||
itemRef={'organizations'}
|
||||
options={initialValues.organizations}
|
||||
component={SelectFieldMany}
|
||||
showField={'name'}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
@ -233,8 +234,24 @@ const TenantsNew = () => {
|
||||
name="properties"
|
||||
id="properties"
|
||||
itemRef={'properties'}
|
||||
options={[]}
|
||||
options={initialValues.properties}
|
||||
component={SelectFieldMany}
|
||||
showField={'name'}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Audit logs"
|
||||
labelFor="audit_logs"
|
||||
help="Optional: attach audit log records that should stay owned by this tenant."
|
||||
>
|
||||
<Field
|
||||
name="audit_logs"
|
||||
id="audit_logs"
|
||||
itemRef={'audit_logs'}
|
||||
options={initialValues.audit_logs}
|
||||
component={SelectFieldMany}
|
||||
showField={'action'}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import PublicSiteFooter from '../components/PublicSiteFooter';
|
||||
import PublicSiteHeader from '../components/PublicSiteHeader';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
@ -173,14 +175,16 @@ export default function PrivacyPolicy() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='prose prose-slate mx-auto max-w-none'>
|
||||
<div className='min-h-screen bg-slate-50 text-slate-950'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Terms of Use')}</title>
|
||||
</Head>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'>
|
||||
<div className='p-8 lg:px-12 lg:py-10'>
|
||||
<PublicSiteHeader />
|
||||
|
||||
<div className='mx-auto flex max-w-6xl justify-center px-6 py-8 lg:px-10 lg:py-10'>
|
||||
<div className='z-10 w-full rounded-2xl border border-slate-200 bg-white shadow-[0_18px_50px_-42px_rgba(15,23,42,0.35)]'>
|
||||
<div className='prose prose-slate max-w-none p-8 lg:px-12 lg:py-10'>
|
||||
<h1>Terms of Use</h1>
|
||||
|
||||
<Information />
|
||||
@ -197,6 +201,8 @@ export default function PrivacyPolicy() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PublicSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user