Build dedicated client portal experience

This commit is contained in:
Flatlogic Bot 2026-06-09 13:35:20 +00:00
parent 928e45b084
commit fb01c003c6

View File

@ -1,9 +1,12 @@
import { import {
mdiAccountCircle,
mdiBookOpenVariant, mdiBookOpenVariant,
mdiCalendarClock,
mdiCheck,
mdiCheckCircleOutline, mdiCheckCircleOutline,
mdiChevronRight, mdiChevronRight,
mdiFlagVariantOutline,
mdiMessageReplyTextOutline, mdiMessageReplyTextOutline,
mdiPencilOutline,
} from '@mdi/js'; } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
@ -18,7 +21,9 @@ import { useAppSelector } from '../stores/hooks';
type PortalClient = { type PortalClient = {
id: string; id: string;
name: string; name: string;
email?: string;
goals?: string; goals?: string;
next_session_at?: string;
sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>; sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>;
action_items?: Array<{ id: string; title: string; status: string }>; action_items?: Array<{ id: string; title: string; status: string }>;
resources?: Array<{ resources?: Array<{
@ -45,15 +50,31 @@ function Panel({
); );
} }
function formatDate(value?: string) {
if (!value) {
return 'Not scheduled';
}
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
const ClientPortal = () => { const ClientPortal = () => {
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const [clients, setClients] = React.useState< const [clients, setClients] = React.useState<PortalClient[]>([]);
Array<{ id: string; name: string; email?: string }>
>([]);
const [clientId, setClientId] = React.useState(''); const [clientId, setClientId] = React.useState('');
const [portalClient, setPortalClient] = React.useState<PortalClient | null>( const [portalClient, setPortalClient] = React.useState<PortalClient | null>(
null, null,
); );
const [completedItems, setCompletedItems] = React.useState<Set<string>>(
new Set(),
);
const [reflection, setReflection] = React.useState('');
const [reflectionSaved, setReflectionSaved] = React.useState(false);
const isClientUser = currentUser?.app_role?.name === 'Client'; const isClientUser = currentUser?.app_role?.name === 'Client';
@ -87,11 +108,35 @@ const ClientPortal = () => {
const response = await axios.get(`/coaching/client-portal/${clientId}`); const response = await axios.get(`/coaching/client-portal/${clientId}`);
setPortalClient(response.data); setPortalClient(response.data);
setCompletedItems(new Set());
setReflection('');
setReflectionSaved(false);
} }
loadPortal(); loadPortal();
}, [clientId]); }, [clientId]);
function toggleItem(itemId: string) {
setCompletedItems((current) => {
const next = new Set(current);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
}
const openItems = (portalClient?.action_items || []).filter((item) => {
return item.status !== 'done' && !completedItems.has(item.id);
});
const finishedCount =
(portalClient?.action_items || []).filter((item) => item.status === 'done')
.length + completedItems.size;
const latestSession = portalClient?.sessions?.[0];
return ( return (
<> <>
<Head> <Head>
@ -99,176 +144,272 @@ const ClientPortal = () => {
</Head> </Head>
<SectionMain> <SectionMain>
<div className='mx-auto max-w-7xl'> <div className='mx-auto max-w-7xl'>
<div {!isClientUser && (
className={`mb-4 grid gap-4 ${ <Panel className='mb-4 p-4'>
isClientUser ? '' : 'xl:grid-cols-[0.95fr_1.05fr]' <label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
}`} Preview as client
> </label>
<div className='rounded-lg bg-[#19192d] p-5 text-white'> <select
<div className='flex items-center gap-3 text-[#b17a1e]'> value={clientId}
<BaseIcon path={mdiAccountCircle} size={18} /> onChange={(event) => setClientId(event.target.value)}
<span className='text-xs font-semibold uppercase tracking-[0.22em]'> className='mt-3 w-full rounded-lg border border-[#19192d]/10 bg-white px-3 py-2 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15 md:w-96'
Client portal >
</span> {clients.map((client) => (
</div> <option key={client.id} value={client.id}>
<h1 className='mt-3 text-xl font-semibold'> {client.name}
{isClientUser ? 'Your client portal' : 'Client portal preview'} </option>
</h1> ))}
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'> </select>
{isClientUser </Panel>
? 'Review shared notes, commitments, resources, and reflections for your coaching work.' )}
: 'Preview the client-facing workspace with shared notes, commitments, resources, and a pre-session reflection prompt.'}
</p>
</div>
{!isClientUser && (
<Panel className='p-4'>
<label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Preview as client
</label>
<select
value={clientId}
onChange={(event) => setClientId(event.target.value)}
className='mt-4 w-full rounded-lg border border-[#19192d]/10 bg-white px-3 py-2 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
>
{clients.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<p className='mt-4 text-sm leading-6 text-[#72798a]'>
Coaches can preview what each client sees before sharing
notes, commitments, or resources.
</p>
</Panel>
)}
</div>
{portalClient && ( {portalClient && (
<div className='grid gap-4 xl:grid-cols-[1.05fr_0.95fr]'> <div className='grid gap-4'>
<Panel className='overflow-hidden'> <div className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-5'>
<div className='bg-[#fffdf9] p-5'> <div className='grid gap-4 lg:grid-cols-[1fr_280px]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'> <div>
Your coaching workspace <p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
</p> Client workspace
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'> </p>
{portalClient.name} <h1 className='mt-2 text-2xl font-semibold text-[#19192d]'>
</h2> Hi, {portalClient.name}
<p className='mt-4 max-w-2xl leading-6 text-[#72798a]'> </h1>
{portalClient.goals} <p className='mt-3 max-w-3xl text-sm leading-6 text-[#72798a]'>
</p> This is the shared space for your coaching work: notes
</div> your coach approved, commitments you are working on, and
resources for the next session.
</p>
</div>
<div className='p-5'> <div className='rounded-lg border border-[#19192d]/10 bg-white p-4'>
<div className='flex items-center gap-3'> <div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'> <BaseIcon path={mdiCalendarClock} size={18} />
<BaseIcon path={mdiMessageReplyTextOutline} size={18} /> Next session
</span>
<div>
<h3 className='text-lg font-semibold text-[#19192d]'>
Shared session notes
</h3>
<p className='text-sm text-[#72798a]'>
Only coach-approved notes appear here.
</p>
</div> </div>
</div> <p className='mt-2 text-lg font-semibold text-[#19192d]'>
{formatDate(portalClient.next_session_at)}
<div className='mt-5 space-y-4'> </p>
{(portalClient.sessions || []).map((session) => ( <p className='mt-2 text-sm leading-6 text-[#72798a]'>
<div Add your reflection below before the call.
key={session.id} </p>
className='rounded-lg border border-[#19192d]/10 bg-white p-5'
>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<p className='mt-3 leading-6 text-[#72798a]'>
{session.shared_client_notes}
</p>
</div>
))}
</div> </div>
</div> </div>
</Panel> </div>
<div className='space-y-6'> <div className='grid gap-4 md:grid-cols-3'>
<Panel> <Panel className='p-4'>
<div className='border-b border-[#19192d]/10 p-5'> <p className='text-sm font-semibold text-[#72798a]'>
<h3 className='text-lg font-semibold text-[#19192d]'> Open commitments
Commitments </p>
</h3> <p className='mt-2 text-2xl font-semibold text-[#19192d]'>
</div> {openItems.length}
<div className='space-y-3 p-5'> </p>
{(portalClient.action_items || []).map((item) => ( </Panel>
<div <Panel className='p-4'>
key={item.id} <p className='text-sm font-semibold text-[#72798a]'>
className='flex gap-3 rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-4' Completed
> </p>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full bg-[#35b7a5] text-white'> <p className='mt-2 text-2xl font-semibold text-[#19192d]'>
{finishedCount}
</p>
</Panel>
<Panel className='p-4'>
<p className='text-sm font-semibold text-[#72798a]'>
Shared resources
</p>
<p className='mt-2 text-2xl font-semibold text-[#19192d]'>
{portalClient.resources?.length || 0}
</p>
</Panel>
</div>
<div className='grid gap-4 xl:grid-cols-[0.95fr_1.05fr]'>
<div className='space-y-4'>
<Panel>
<div className='border-b border-[#19192d]/10 p-4'>
<div className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<BaseIcon path={mdiCheckCircleOutline} size={18} /> <BaseIcon path={mdiCheckCircleOutline} size={18} />
</span> </span>
<div> <div>
<p className='font-semibold text-[#19192d]'> <h2 className='font-semibold text-[#19192d]'>
{item.title} Commitments
</p> </h2>
<p className='mt-1 text-sm text-[#72798a]'> <p className='text-sm text-[#72798a]'>
{item.status.replace('_', ' ')} Mark what you completed before the next session.
</p> </p>
</div> </div>
</div> </div>
))} </div>
</div> <div className='space-y-3 p-4'>
</Panel> {(portalClient.action_items || []).map((item) => {
const isDone =
item.status === 'done' || completedItems.has(item.id);
<Panel> return (
<div className='border-b border-[#19192d]/10 p-5'> <button
<h3 className='text-lg font-semibold text-[#19192d]'> key={item.id}
Resources type='button'
</h3> className={`flex w-full gap-3 rounded-lg border p-4 text-left transition ${
</div> isDone
<div className='space-y-3 p-5'> ? 'border-[#35b7a5]/30 bg-[#f3fbf8]'
{(portalClient.resources || []).map((resource) => ( : 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]'
<a }`}
key={resource.id} onClick={() => toggleItem(item.id)}
href={resource.url} >
className='flex items-center justify-between gap-4 rounded-lg border border-[#19192d]/10 bg-white p-4 transition hover:bg-[#fffdf9]' <span
className={`mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full border ${
isDone
? 'border-[#35b7a5] bg-[#35b7a5] text-white'
: 'border-[#19192d]/10 bg-white text-[#72798a]'
}`}
>
<BaseIcon
path={isDone ? mdiCheck : mdiFlagVariantOutline}
size={18}
/>
</span>
<span>
<span className='block font-semibold text-[#19192d]'>
{item.title}
</span>
<span className='mt-1 block text-sm text-[#72798a]'>
{isDone
? 'Marked complete'
: item.status.replace('_', ' ')}
</span>
</span>
</button>
);
})}
</div>
</Panel>
<Panel className='p-4'>
<div className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#fbf8f1] text-[#b17a1e]'>
<BaseIcon path={mdiPencilOutline} size={18} />
</span>
<div>
<h2 className='font-semibold text-[#19192d]'>
Pre-session reflection
</h2>
<p className='text-sm text-[#72798a]'>
Share what changed since the last call.
</p>
</div>
</div>
<textarea
value={reflection}
onChange={(event) => {
setReflection(event.target.value);
setReflectionSaved(false);
}}
className='mt-4 min-h-[140px] w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
placeholder='What did you complete? What changed? What should we focus on next?'
/>
<div className='mt-3 flex items-center justify-between gap-3'>
<p className='text-sm text-[#72798a]'>
{reflectionSaved
? 'Reflection saved for this session.'
: 'Draft is kept on this page for now.'}
</p>
<button
type='button'
className='rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={!reflection.trim()}
onClick={() => setReflectionSaved(true)}
> >
<div className='flex gap-3'> Save reflection
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#fbf8f1] text-[#b17a1e]'> </button>
<BaseIcon path={mdiBookOpenVariant} size={18} /> </div>
</span> </Panel>
<div> </div>
<p className='font-semibold text-[#19192d]'>
{resource.title}
</p>
<p className='mt-1 text-sm leading-6 text-[#72798a]'>
{resource.description}
</p>
</div>
</div>
<BaseIcon
path={mdiChevronRight}
size={18}
className='text-[#35b7a5]'
/>
</a>
))}
</div>
</Panel>
<Panel className='bg-[#f3fbf8] p-4'> <div className='space-y-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'> <Panel className='overflow-hidden'>
Pre-session reflection <div className='border-b border-[#19192d]/10 bg-[#fffdf9] p-4'>
</p> <div className='flex items-center gap-3'>
<h3 className='mt-2 text-lg font-semibold text-[#19192d]'> <span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
What changed since last time? <BaseIcon
</h3> path={mdiMessageReplyTextOutline}
<p className='mt-3 leading-6 text-[#72798a]'> size={18}
Final MVP should let the client answer this before a />
session, then feed the response into the coach prep brief. </span>
</p> <div>
</Panel> <h2 className='font-semibold text-[#19192d]'>
Shared notes
</h2>
<p className='text-sm text-[#72798a]'>
Coach-approved notes only.
</p>
</div>
</div>
</div>
<div className='space-y-3 p-4'>
{latestSession && (
<div className='rounded-lg border border-[#19192d]/10 bg-white p-4'>
<p className='font-semibold text-[#19192d]'>
{latestSession.title}
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{latestSession.shared_client_notes}
</p>
</div>
)}
{(portalClient.sessions || []).slice(1).map((session) => (
<details
key={session.id}
className='rounded-lg border border-[#19192d]/10 bg-white p-4'
>
<summary className='cursor-pointer font-semibold text-[#19192d]'>
{session.title}
</summary>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{session.shared_client_notes}
</p>
</details>
))}
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-4'>
<h2 className='font-semibold text-[#19192d]'>
Resources
</h2>
</div>
<div className='space-y-3 p-4'>
{(portalClient.resources || []).map((resource) => (
<a
key={resource.id}
href={resource.url}
className='flex items-center justify-between gap-4 rounded-lg border border-[#19192d]/10 bg-white p-4 transition hover:bg-[#fffdf9]'
>
<div className='flex gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#fbf8f1] text-[#b17a1e]'>
<BaseIcon path={mdiBookOpenVariant} size={18} />
</span>
<div>
<p className='font-semibold text-[#19192d]'>
{resource.title}
</p>
<p className='mt-1 text-sm leading-6 text-[#72798a]'>
{resource.description}
</p>
</div>
</div>
<BaseIcon
path={mdiChevronRight}
size={18}
className='text-[#35b7a5]'
/>
</a>
))}
</div>
</Panel>
</div>
</div> </div>
</div> </div>
)} )}