92 lines
25 KiB
TypeScript
92 lines
25 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react';
|
|
import { api, session, type Session, type Hotel, type Conversation, type Knowledge, type Activity, type Mailboxes } from './api';
|
|
import './style.css';
|
|
import { OperationsPage } from './OperationsPage';
|
|
import { MailboxPanel } from './MailboxPanel';
|
|
import { TeamPage, OnboardingPage, AccountPage } from './TeamPage';
|
|
import { AutomationPage } from './AutomationPage';
|
|
import { PaymentsPage } from './PaymentsPage';
|
|
import { PmsPage } from './PmsPage';
|
|
import { ReplyActions, ReplyControls } from './ReplyActions';
|
|
|
|
const labels: Record<string,string> = { NeedsAttention: 'Needs attention', DraftReady: 'Draft ready', Completed: 'Completed' };
|
|
const initials = (name: string) => name.replace(/<.*>/, '').trim().split(' ').filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase();
|
|
const sender = (name: string) => name.replace(/<.*>/, '').replaceAll('"', '').trim();
|
|
const date = (value: string) => new Date(value).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
|
|
function App() {
|
|
const [auth,setAuth] = useState<Session|null>(null), [error,setError] = useState(''), [notice,setNotice] = useState('');
|
|
const [hotel,setHotel] = useState<Hotel|null>(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false);
|
|
const [conversations,setConversations] = useState<Conversation[]>([]), [knowledge,setKnowledge] = useState<Knowledge[]>([]), [activity,setActivity] = useState<Activity[]>([]), [mailboxes,setMailboxes] = useState<Mailboxes>({ configured:false,items:[] });
|
|
const [loaded,setLoaded] = useState(false);
|
|
async function refresh() {
|
|
const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<Conversation[]>('/conversations'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]);
|
|
setHotel(h);setConversations(c);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
|
|
}
|
|
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>setPage(location.pathname);window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
|
|
useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]);
|
|
useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
|
|
async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} }
|
|
function go(path:string) { if (!window.dispatchEvent(new Event('workspace-navigate', { cancelable:true }))) return; setPage(path);history.pushState({},'',path);setError(''); }
|
|
async function login(email:string,password:string) { await run(async()=>{await api('/auth/login','POST',{email,password});setAuth(await session());}); }
|
|
async function preview() { await run(async()=>{await api('/preview/start','POST');setAuth(await session());}); }
|
|
async function logout() { await run(async()=>{await api('/auth/logout','POST');setAuth(await session());setHotel(null);setLoaded(false);}); }
|
|
const errorBox=error?<div className="alert" role="alert"><CircleHelp size={18}/><span>{error}</span><button className="icon-button" onClick={()=>setError('')} aria-label="Dismiss error"><X size={17}/></button></div>:null;
|
|
if(!auth) return <div className="loading"><span className="brand-mark">g</span><p>Opening your workspace…</p>{errorBox}</div>;
|
|
if(page==="/account") return <AccountPage/>;
|
|
if(!auth.user) return <Login preview={auth.preview} onLogin={login} onPreview={preview} busy={busy} error={errorBox}/>;
|
|
const count=conversations.filter(c=>c.status!=='Completed').length;
|
|
return <div className="app-shell">
|
|
<aside className="sidebar">
|
|
<a href="/inbox" aria-label="GuestOps inbox" className="brand" onClick={e=>{e.preventDefault();go('/inbox');}}><span className="brand-mark">g</span><span>guestops<span className="brand-dot">.</span></span></a>
|
|
<div className="hotel-switch"><span className="hotel-icon"><Building2 size={19}/></span><div><strong>{hotel?.name||'Your hotel'}</strong><small>Hotel workspace</small></div></div>
|
|
<div className="nav-label">WORKSPACE</div>
|
|
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/reservations',label:'Reservations',icon:Building2},{path:'/payments',label:'Payments',icon:Banknote},{path:'/automation',label:'FAQ automation',icon:ShieldCheck},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings},{path:'/team',label:'Your team',icon:ShieldCheck},{path:'/setup',label:'Hotel setup',icon:CheckCheck},{path:'/health',label:'Workspace health',icon:ActivityIcon}].filter(n=>auth.user?.role==='Owner'||!['/team','/setup','/health'].includes(n.path)).map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
|
<div className="sidebar-bottom"><div className="mode-card"><ShieldCheck size={20}/><strong>You're in control</strong><p>Your team controls approved answers and reply automation.</p></div><div className="profile"><span className="avatar profile-avatar">{initials(auth.user.name)}</span><div><strong>{auth.user.name}</strong><small>{auth.user.role==='Owner'?'Hotel owner':'Team member'}</small></div><button className="icon-button" onClick={logout} disabled={busy} aria-label="Sign out"><LogOut size={18}/></button></div></div>
|
|
</aside>
|
|
<main className="main">
|
|
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
|
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
|
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
|
</main>
|
|
</div>;
|
|
}
|
|
function PageHeading({eyebrow,title,text}:{eyebrow:string;title:string;text:string}) { return <div className="page-heading"><span className="eyebrow">{eyebrow}</span><h1>{title}</h1><p>{text}</p></div>; }
|
|
function Empty({title,text}:{title:string;text:string}) {return <div className="empty"><Mail size={34}/><h2>{title}</h2><p>{text}</p></div>;}
|
|
function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise<void>;onPreview:()=>Promise<void>;busy:boolean;error:React.ReactNode}){
|
|
const [email,setEmail]=useState(''),[password,setPassword]=useState('');
|
|
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>Welcome back</h1><p>Sign in to take care of your guests.</p>{error}<form onSubmit={e=>{e.preventDefault();onLogin(email,password);}}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><p className="small muted">Need access or help signing in? Contact your hotel administrator.</p>{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</div></section></div>;
|
|
}
|
|
type Run=(a:()=>Promise<void>)=>Promise<void>;
|
|
function InboxPage({hotel,mailboxes,conversations,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){
|
|
const [filter,setFilter]=useState('All'),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
|
|
const filtered=conversations.filter(c=>(filter==='All'||c.status===filter)&&(c.subject+' '+c.from+' '+c.body).toLowerCase().includes(search.toLowerCase()));
|
|
const current=filtered.find(c=>c.id===selected)||filtered[0];
|
|
useEffect(()=>{setDraft(current?.draft||'');},[current?.id,current?.draft]);
|
|
useEffect(()=>{if(!current || draft===current.draft)return;const leave=(e:BeforeUnloadEvent)=>{e.preventDefault();};const navigate=(e:Event)=>{if(!window.confirm('Discard your unsaved draft changes?'))e.preventDefault();};window.addEventListener('beforeunload',leave);window.addEventListener('workspace-navigate',navigate);return()=>{window.removeEventListener('beforeunload',leave);window.removeEventListener('workspace-navigate',navigate);};},[current?.id,current?.draft,draft]);
|
|
const needs=conversations.filter(c=>c.status==='NeedsAttention').length,ready=conversations.filter(c=>c.status==='DraftReady').length;
|
|
function select(c:Conversation) { if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setSelected(c.id);setMobileDetail(true); }
|
|
async function save() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/draft`,'PUT',{draft,version:current.version}));notify('Draft saved. Nothing has been sent.');});}
|
|
async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});}
|
|
return <div className="inbox-page"><div className="inbox-heading"><PageHeading eyebrow="A warm welcome starts here" title="Your guest inbox" text={needs?`${needs} conversations need your attention. Let's make their day.`:'A little space to focus on your guests.'}/><div className="mini-stats"><div><strong>{needs}</strong><span>Need attention</span></div><div><strong>{ready}</strong><span>Drafts ready</span></div></div></div>
|
|
<div className="inbox-toolbar"><div className="tabs" role="group" aria-label="Filter conversations">{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=><button key={key} className={filter===key?'tab selected':'tab'} onClick={()=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setFilter(key);}}>{label}{key==='NeedsAttention'&&needs>0&&<span>{needs}</span>}</button>)}</div><label className="search"><Search size={17}/><input aria-label="Search conversations" placeholder="Search messages…" value={search} onChange={e=>setSearch(e.target.value)}/></label></div>
|
|
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
|
|
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&¤t.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
|
|
}
|
|
function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge[];canEdit:boolean;busy:boolean;run:Run;onUpdate:(k:Knowledge)=>void;notify:(s:string)=>void}){
|
|
const [edit,setEdit]=useState<Knowledge|null>(null),[search,setSearch]=useState('');
|
|
useEffect(()=>{if(!edit)return;const previous=document.activeElement;const key=(e:KeyboardEvent)=>{if(e.key==='Escape'){e.preventDefault();setEdit(null);}if(e.key==='Tab'){const nodes=Array.from(document.querySelectorAll<HTMLElement>('.modal button:not(:disabled),.modal input,.modal textarea,.modal select'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous instanceof HTMLElement)previous.focus();};},[edit?.id]);
|
|
async function save(e:React.FormEvent) {e.preventDefault();if(!edit)return;await run(async()=>{const result=await api<Knowledge>('/knowledge'+(edit.id?'/'+edit.id:''),edit.id?'PUT':'POST',edit);onUpdate(result);setEdit(null);notify('Hotel knowledge saved.');});}
|
|
return <div className="page"><div className="heading-row"><PageHeading eyebrow="Answers your team can trust" title="Hotel knowledge" text="Keep your policies and helpful answers in one place."/>{canEdit&&<button className="button primary" onClick={()=>setEdit({id:'',title:'',answer:'',keywords:'',category:'General',approved:false,version:0})}><Plus size={17}/>Add an answer</button>}</div><div className="knowledge-summary"><BookOpen size={23}/><div><strong>Your hotel's source of truth</strong><p>Approve answers before your team uses them in a reply. Keep changing details up to date.</p></div><span>{items.filter(x=>x.approved).length} approved</span></div><label className="search knowledge-search"><Search size={17}/><input placeholder="Find an answer…" aria-label="Search hotel knowledge" value={search} onChange={e=>setSearch(e.target.value)}/></label><div className="knowledge-grid">{items.filter(k=>(k.title+' '+k.answer).toLowerCase().includes(search.toLowerCase())).map(k=><article className="knowledge-card" key={k.id}><div><span className="category-label">{k.category}</span><span className={'status '+(k.approved?'Completed':'NeedsAttention')}>{k.approved?'Approved':'Not approved'}</span></div><h2>{k.title}</h2><p>{k.answer}</p>{canEdit&&<button className="button text" onClick={()=>setEdit({...k})}>Edit answer<ArrowUpRight size={15}/></button>}</article>)}</div>{!items.length&&<Empty title="What should guests know?" text="Start with check-in times, parking and breakfast information."/>}{edit&&<div className="modal-backdrop"><section className="modal" role="dialog" aria-modal="true" aria-labelledby="knowledge-title"><div className="modal-heading"><h2 id="knowledge-title">{edit.id?'Edit answer':'Add an answer'}</h2><button className="icon-button" onClick={()=>setEdit(null)} aria-label="Close editor"><X size={20}/></button></div><form onSubmit={save}><label>Title<input autoFocus value={edit.title} maxLength={120} minLength={2} required onChange={e=>setEdit({...edit,title:e.target.value})}/></label><label>Category<input value={edit.category} maxLength={50} required onChange={e=>setEdit({...edit,category:e.target.value})}/></label><label>Answer<textarea value={edit.answer} rows={6} maxLength={5000} minLength={2} required onChange={e=>setEdit({...edit,answer:e.target.value})}/></label><label>Helpful keywords<input value={edit.keywords} maxLength={300} placeholder="parking, car, arrival" onChange={e=>setEdit({...edit,keywords:e.target.value})}/></label><label className="checkbox-label"><input type="checkbox" checked={edit.approved} onChange={e=>setEdit({...edit,approved:e.target.checked})}/>Approved for staff to use</label><div className="form-actions"><button type="button" className="button secondary" onClick={()=>setEdit(null)}>Cancel</button><button className="button primary" disabled={busy}>Save answer</button></div></form></section></div>}</div>;
|
|
}
|
|
function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave,onMailboxes}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void;onMailboxes:(value:Mailboxes)=>void}){
|
|
const [form,setForm]=useState(hotel);useEffect(()=>setForm(hotel),[hotel]);
|
|
const result=new URLSearchParams(location.search).get('google');
|
|
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><MailboxPanel data={mailboxes} owner={owner} preview={preview} busy={busy} run={run} onChange={onMailboxes}/><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={owner} busy={busy} run={run} onSave={onSave}/></div>;
|
|
}
|
|
createRoot(document.getElementById('root')!).render(<App/>);
|
|
|
|
|