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,36 +144,15 @@ const ClientPortal = () => {
</Head> </Head>
<SectionMain> <SectionMain>
<div className='mx-auto max-w-7xl'> <div className='mx-auto max-w-7xl'>
<div
className={`mb-4 grid gap-4 ${
isClientUser ? '' : 'xl:grid-cols-[0.95fr_1.05fr]'
}`}
>
<div className='rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<BaseIcon path={mdiAccountCircle} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Client portal
</span>
</div>
<h1 className='mt-3 text-xl font-semibold'>
{isClientUser ? 'Your client portal' : 'Client portal preview'}
</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
{isClientUser
? '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 && ( {!isClientUser && (
<Panel className='p-4'> <Panel className='mb-4 p-4'>
<label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'> <label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Preview as client Preview as client
</label> </label>
<select <select
value={clientId} value={clientId}
onChange={(event) => setClientId(event.target.value)} 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' 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'
> >
{clients.map((client) => ( {clients.map((client) => (
<option key={client.id} value={client.id}> <option key={client.id} value={client.id}>
@ -136,98 +160,227 @@ const ClientPortal = () => {
</option> </option>
))} ))}
</select> </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> </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]'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'> <p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Your coaching workspace Client workspace
</p> </p>
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'> <h1 className='mt-2 text-2xl font-semibold text-[#19192d]'>
{portalClient.name} Hi, {portalClient.name}
</h2> </h1>
<p className='mt-4 max-w-2xl leading-6 text-[#72798a]'> <p className='mt-3 max-w-3xl text-sm leading-6 text-[#72798a]'>
{portalClient.goals} This is the shared space for your coaching work: notes
your coach approved, commitments you are working on, and
resources for the next session.
</p> </p>
</div> </div>
<div className='p-5'> <div className='rounded-lg border border-[#19192d]/10 bg-white p-4'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
Next session
</div>
<p className='mt-2 text-lg font-semibold text-[#19192d]'>
{formatDate(portalClient.next_session_at)}
</p>
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
Add your reflection below before the call.
</p>
</div>
</div>
</div>
<div className='grid gap-4 md:grid-cols-3'>
<Panel className='p-4'>
<p className='text-sm font-semibold text-[#72798a]'>
Open commitments
</p>
<p className='mt-2 text-2xl font-semibold text-[#19192d]'>
{openItems.length}
</p>
</Panel>
<Panel className='p-4'>
<p className='text-sm font-semibold text-[#72798a]'>
Completed
</p>
<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'> <div className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'> <span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<BaseIcon path={mdiMessageReplyTextOutline} size={18} />
</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 className='mt-5 space-y-4'>
{(portalClient.sessions || []).map((session) => (
<div
key={session.id}
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>
</Panel>
<div className='space-y-6'>
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Commitments
</h3>
</div>
<div className='space-y-3 p-5'>
{(portalClient.action_items || []).map((item) => (
<div
key={item.id}
className='flex gap-3 rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-4'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full bg-[#35b7a5] text-white'>
<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]'>
Commitments
</h2>
<p className='text-sm text-[#72798a]'>
Mark what you completed before the next session.
</p>
</div>
</div>
</div>
<div className='space-y-3 p-4'>
{(portalClient.action_items || []).map((item) => {
const isDone =
item.status === 'done' || completedItems.has(item.id);
return (
<button
key={item.id}
type='button'
className={`flex w-full gap-3 rounded-lg border p-4 text-left transition ${
isDone
? 'border-[#35b7a5]/30 bg-[#f3fbf8]'
: 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]'
}`}
onClick={() => toggleItem(item.id)}
>
<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} {item.title}
</p> </span>
<p className='mt-1 text-sm text-[#72798a]'> <span className='mt-1 block text-sm text-[#72798a]'>
{item.status.replace('_', ' ')} {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> </p>
</div> </div>
</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)}
>
Save reflection
</button>
</div>
</Panel>
</div>
<div className='space-y-4'>
<Panel className='overflow-hidden'>
<div className='border-b border-[#19192d]/10 bg-[#fffdf9] 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={mdiMessageReplyTextOutline}
size={18}
/>
</span>
<div>
<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> </div>
</Panel> </Panel>
<Panel> <Panel>
<div className='border-b border-[#19192d]/10 p-5'> <div className='border-b border-[#19192d]/10 p-4'>
<h3 className='text-lg font-semibold text-[#19192d]'> <h2 className='font-semibold text-[#19192d]'>
Resources Resources
</h3> </h2>
</div> </div>
<div className='space-y-3 p-5'> <div className='space-y-3 p-4'>
{(portalClient.resources || []).map((resource) => ( {(portalClient.resources || []).map((resource) => (
<a <a
key={resource.id} key={resource.id}
@ -256,19 +409,7 @@ const ClientPortal = () => {
))} ))}
</div> </div>
</Panel> </Panel>
</div>
<Panel className='bg-[#f3fbf8] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Pre-session reflection
</p>
<h3 className='mt-2 text-lg font-semibold text-[#19192d]'>
What changed since last time?
</h3>
<p className='mt-3 leading-6 text-[#72798a]'>
Final MVP should let the client answer this before a
session, then feed the response into the coach prep brief.
</p>
</Panel>
</div> </div>
</div> </div>
)} )}