Add stable inbox cursor pagination

This commit is contained in:
wolf-demon 2026-09-29 19:28:26 +01:00
parent d1e27c0e47
commit 47178a64ce
8 changed files with 51 additions and 10 deletions

View File

@ -41,7 +41,7 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
| 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. |
| 15 | Knowledge, AI, and FAQ activation | B | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. |
| 16 | Identity, preferences, and privacy | B/C | Planned | Finish operational identity controls, privacy/retention decisions, hotel preferences, audit review, and proxy-aware login rate limiting. |
| 17 | Inbox usability and desktop parity | B | Planned | Add safe pagination beyond 500 conversations, protect unsaved drafts across all navigation/filter/search paths, honour hotel timezones, and close agreed desktop-parity gaps. |
| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Complete hotel-timezone rendering and agreed desktop-parity acceptance. |
| 18 | Pilot, capacity, and release approval | B/C | Planned | Run the supervised pilot, exercise support and incident procedures, validate capacity, resolve pilot findings, and capture explicit go/no-go approval for wider rollout. |
## Delivery sequence

View File

@ -10,6 +10,7 @@ The reviewed candidate is now promoted into the local `main` history. It is not
- Approval-controlled OHIP PMS and NMI payment workflows.
- FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery.
- No-send FAQ batch evaluation with false-positive and false-negative reporting before activation.
- Stable tenant-scoped inbox pagination beyond the former 500-message view, stronger unsaved-draft navigation guards, and hotel-timezone inbox timestamps.
- Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling.
These capabilities still require their separately documented provider, host and operational acceptance. Google, PMS and payment-provider acceptance is not established by local automated tests.

View File

@ -0,0 +1,19 @@
using System.Text;
namespace GuestOps.Web;
public sealed record ConversationPage(IReadOnlyList<Conversation> Items,string? NextCursor);
public static class ConversationPaging
{
public static string Encode(Conversation item)=>Convert.ToBase64String(Encoding.UTF8.GetBytes(item.ReceivedAt.Ticks+"|"+item.Id)).TrimEnd('=').Replace('+','-').Replace('/','_');
public static bool TryDecode(string? value,out DateTime at,out string id)
{
at=default;id="";if(string.IsNullOrEmpty(value))return true;
try
{
if(value.Length>160)return false;var raw=value.Replace('-','+').Replace('_','/');raw=raw.PadRight((raw.Length+3)/4*4,'=');
var parts=Encoding.UTF8.GetString(Convert.FromBase64String(raw)).Split('|');
return parts.Length==2&&long.TryParse(parts[0],out var ticks)&&ticks>=DateTime.MinValue.Ticks&&ticks<=DateTime.MaxValue.Ticks&&System.Text.RegularExpressions.Regex.IsMatch(parts[1],"^[a-f0-9]{32}$")&&(at=new DateTime(ticks,DateTimeKind.Utc))!=default&&(id=parts[1]).Length>0;
}
catch{return false;}
}
}

View File

@ -169,6 +169,7 @@ api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel);
}).RequireAuthorization("Owner");
api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt)));
api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."}));
api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound());
api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) =>
{

View File

@ -11,6 +11,7 @@ public interface IStore
Task<DateTime?> WorkerLastSeen();
Task RecordWorkerHeartbeat();
Task<List<T>> List<T>(string hotel) where T : TenantDocument;
Task<ConversationPage> ConversationPage(string hotel,DateTime? before,string beforeId,int limit);
Task<T?> Get<T>(string hotel, string id) where T : TenantDocument;
Task Insert<T>(T document) where T : TenantDocument;
Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument;
@ -78,6 +79,12 @@ public sealed class MongoStore : IStore
if (typeof(T) == typeof(PmsChange) || typeof(T) == typeof(PaymentRequest)) query = query.Sort(Builders<T>.Sort.Descending("UpdatedAt"));
return query.Limit(500).ToListAsync();
}
public async Task<ConversationPage> ConversationPage(string hotel,DateTime? before,string beforeId,int limit)
{
var filter=Scope<Conversation>(hotel);if(before!=null)filter&=Builders<Conversation>.Filter.Lt(x=>x.ReceivedAt,before.Value)|(Builders<Conversation>.Filter.Eq(x=>x.ReceivedAt,before.Value)&Builders<Conversation>.Filter.Lt(x=>x.Id,beforeId));
var rows=await Collection<Conversation>().Find(filter).SortByDescending(x=>x.ReceivedAt).ThenByDescending(x=>x.Id).Limit(limit+1).ToListAsync();
var more=rows.Count>limit;if(more)rows.RemoveAt(rows.Count-1);return new(rows,more?ConversationPaging.Encode(rows[^1]):null);
}
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().Find(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync();
public Task Insert<T>(T document) where T : TenantDocument
{
@ -179,6 +186,11 @@ public sealed class PreviewStore : IStore
static string Json<T>(T value) => System.Text.Json.JsonSerializer.Serialize(value);
public Task Initialize() => Task.CompletedTask;
public Task<List<T>> List<T>(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone<T>(x.Value)).Where(x => x.HotelId == hotel).ToList());
public async Task<ConversationPage> ConversationPage(string hotel,DateTime? before,string beforeId,int limit)
{
var query=(await List<Conversation>(hotel)).OrderByDescending(x=>x.ReceivedAt).ThenByDescending(x=>x.Id).Where(x=>before==null||x.ReceivedAt<before||x.ReceivedAt==before&&string.CompareOrdinal(x.Id,beforeId)<0).Take(limit+1).ToList();
var more=query.Count>limit;if(more)query.RemoveAt(query.Count-1);return new(query,more?ConversationPaging.Encode(query[^1]):null);
}
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => (await List<T>(hotel)).SingleOrDefault(x => x.Id == id);
public Task Insert<T>(T document) where T : TenantDocument { if (!rows.TryAdd(Key<T>(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; }
public Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument

View File

@ -36,6 +36,11 @@ try
await store.Import(row); await store.Import(row);
Check("Duplicate email import is idempotent", (await store.List<Conversation>(a.Id)).Count == 1);
Check("Inbox queries enforce hotel boundary", (await store.List<Conversation>(b.Id)).Count == 0);
var pagingHotel=new Hotel{Name="Paging hotel"};pagingHotel.HotelId=pagingHotel.Id;await store.Insert(pagingHotel);var pagingNow=DateTime.UtcNow;var pagingAt=new DateTime(pagingNow.Ticks-pagingNow.Ticks%TimeSpan.TicksPerMillisecond,DateTimeKind.Utc);
foreach(var id in new[]{"00000000000000000000000000000003","00000000000000000000000000000002","00000000000000000000000000000001"})await store.Insert(new Conversation{Id=id,HotelId=pagingHotel.Id,ReceivedAt=pagingAt,Subject=id});
var firstPage=await store.ConversationPage(pagingHotel.Id,null,"",2);Check("Conversation page has stable bounded cursor",firstPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000003","00000000000000000000000000000002"})&&firstPage.NextCursor!=null);
Check("Conversation cursor round-trips safely",ConversationPaging.TryDecode(firstPage.NextCursor,out var pageAt,out var pageId)&&pageAt==pagingAt&&pageId=="00000000000000000000000000000002"&&!ConversationPaging.TryDecode("not-a-cursor",out _,out _));
var secondPage=await store.ConversationPage(pagingHotel.Id,pageAt,pageId,2);Check("Conversation pagination has no overlap or tenant leakage",secondPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000001"})&&secondPage.NextCursor==null);
var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 };
Check("Settings update succeeds with current version", await store.Replace(a.Id,a.Id,0,altered));
Check("Concurrent stale edit rejected", !await store.Replace(a.Id,a.Id,0,altered));

View File

@ -2,6 +2,7 @@ export type User = { id: string; name: string; role: string; hotelId: string };
export type Session = { preview: boolean; csrfToken: string; user: User | null };
export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; autoReplyMode: string; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean };
export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null };
export type ConversationPage = { items: Conversation[]; nextCursor: string | null };
export type Knowledge = { id: string; title: string; category: string; answer: string; keywords: string; approved: boolean; version: number };
export type Activity = { id: string; at: string; userName: string; action: string };
export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; items: { id: string; email: string; status: string; version: number; lastSyncAt: string | null; lastAttemptAt: string | null; nextAttemptAt: string | null; failureCount: number; syncErrorCode: string; catchingUp: boolean; syncError: string; canSend: boolean }[] };

View File

@ -1,7 +1,7 @@
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 { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
import './style.css';
import { OperationsPage } from './OperationsPage';
import { MailboxPanel } from './MailboxPanel';
@ -14,17 +14,19 @@ 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' });
const date = (value: string,timeZone: string) => new Date(value).toLocaleString(undefined, { timeZone, 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 [conversationCursor,setConversationCursor]=useState<string|null>(null);
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);
const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<ConversationPage>('/conversations/page'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]);
setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
}
async function moreConversations(){if(!conversationCursor)return;const page=await api<ConversationPage>('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);}
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]);
@ -49,7 +51,7 @@ function App() {
<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.');}}/>}
{!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} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} 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,hotel!.timezone)}</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>;
}
@ -60,7 +62,7 @@ function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(
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}){
function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;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];
@ -71,9 +73,9 @@ function InboxPage({hotel,mailboxes,conversations,knowledge,busy,run,onUpdate,no
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&&current.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>;
<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 loaded messages…" value={search} onChange={e=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;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} loaded 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([],{timeZone:hotel.timezone,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>)}{hasMore&&<button className="button secondary wide" disabled={busy} onClick={loadMore}>Load 50 older conversations</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&&current.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,hotel.timezone)}</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('');