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 {
mdiAccountCircle,
mdiBookOpenVariant,
mdiCalendarClock,
mdiCheck,
mdiCheckCircleOutline,
mdiChevronRight,
mdiFlagVariantOutline,
mdiMessageReplyTextOutline,
mdiPencilOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
@ -18,7 +21,9 @@ import { useAppSelector } from '../stores/hooks';
type PortalClient = {
id: string;
name: string;
email?: string;
goals?: string;
next_session_at?: string;
sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>;
action_items?: Array<{ id: string; title: string; status: string }>;
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 { currentUser } = useAppSelector((state) => state.auth);
const [clients, setClients] = React.useState<
Array<{ id: string; name: string; email?: string }>
>([]);
const [clients, setClients] = React.useState<PortalClient[]>([]);
const [clientId, setClientId] = React.useState('');
const [portalClient, setPortalClient] = React.useState<PortalClient | 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';
@ -87,11 +108,35 @@ const ClientPortal = () => {
const response = await axios.get(`/coaching/client-portal/${clientId}`);
setPortalClient(response.data);
setCompletedItems(new Set());
setReflection('');
setReflectionSaved(false);
}
loadPortal();
}, [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 (
<>
<Head>
@ -99,176 +144,272 @@ const ClientPortal = () => {
</Head>
<SectionMain>
<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 && (
<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>
{!isClientUser && (
<Panel className='mb-4 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-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) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</Panel>
)}
{portalClient && (
<div className='grid gap-4 xl:grid-cols-[1.05fr_0.95fr]'>
<Panel className='overflow-hidden'>
<div className='bg-[#fffdf9] p-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Your coaching workspace
</p>
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'>
{portalClient.name}
</h2>
<p className='mt-4 max-w-2xl leading-6 text-[#72798a]'>
{portalClient.goals}
</p>
</div>
<div className='grid gap-4'>
<div className='rounded-lg border border-[#19192d]/10 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]'>
Client workspace
</p>
<h1 className='mt-2 text-2xl font-semibold text-[#19192d]'>
Hi, {portalClient.name}
</h1>
<p className='mt-3 max-w-3xl text-sm leading-6 text-[#72798a]'>
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>
</div>
<div className='p-5'>
<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>
<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 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>
</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>
))}
<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>
</Panel>
</div>
<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'>
<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'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
</span>
<div>
<p className='font-semibold text-[#19192d]'>
{item.title}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{item.status.replace('_', ' ')}
<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>
</Panel>
</div>
<div className='space-y-3 p-4'>
{(portalClient.action_items || []).map((item) => {
const isDone =
item.status === 'done' || completedItems.has(item.id);
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Resources
</h3>
</div>
<div className='space-y-3 p-5'>
{(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]'
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}
</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'>
<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>
Save reflection
</button>
</div>
</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 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>
</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>
)}