Build dedicated client portal experience
This commit is contained in:
parent
928e45b084
commit
fb01c003c6
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user