Add coaching intake lead flow

This commit is contained in:
Flatlogic Bot 2026-06-09 14:39:06 +00:00
parent 677724e9f6
commit 2a10483480
9 changed files with 563 additions and 10 deletions

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -18,6 +17,7 @@ const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai');
const coachingRoutes = require('./routes/coaching');
const coachingPublicRoutes = require('./routes/coachingPublic');
@ -81,6 +81,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/coaching-public', coachingPublicRoutes);
app.enable('trust proxy');

View File

@ -70,6 +70,75 @@ router.get(
}),
);
router.get(
"/intake-leads",
wrapAsync(async (req, res) => {
const leads = await db.intake_leads.findAll({
order: [["createdAt", "DESC"]],
});
res.status(200).send(leads);
}),
);
router.patch(
"/intake-leads/:id/status",
wrapAsync(async (req, res) => {
const lead = await db.intake_leads.findByPk(req.params.id);
if (!lead) {
res.status(404).send({ error: "lead_not_found" });
return;
}
await lead.update({
status: req.body.status,
updatedById: req.currentUser.id,
});
res.status(200).send(lead);
}),
);
router.post(
"/intake-leads/:id/convert",
wrapAsync(async (req, res) => {
const lead = await db.intake_leads.findByPk(req.params.id);
if (!lead) {
res.status(404).send({ error: "lead_not_found" });
return;
}
const client = await db.clients.create({
name: lead.name,
email: lead.email,
company: lead.company,
role_title: lead.role_title,
status: "active",
goals: lead.goal,
notes: [
lead.challenge && `Challenge: ${lead.challenge}`,
lead.desired_outcome && `Desired outcome: ${lead.desired_outcome}`,
lead.consent_ai_notes ? "Consented to AI-assisted session notes." : "AI notes consent not granted yet.",
]
.filter(Boolean)
.join("\n"),
tags: "intake",
ownerId: req.currentUser.id,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
await lead.update({
status: "converted",
updatedById: req.currentUser.id,
});
res.status(200).send({ lead, client });
}),
);
router.get(
"/clients",
wrapAsync(async (req, res) => {

View File

@ -0,0 +1,41 @@
const express = require("express");
const db = require("../db/models");
const wrapAsync = require("../helpers").wrapAsync;
const router = express.Router();
router.post(
"/intake",
wrapAsync(async (req, res) => {
const data = req.body || {};
const name = String(data.name || "").trim();
const email = String(data.email || "").trim();
if (!name) {
res.status(400).send({ error: "name_required" });
return;
}
if (!email) {
res.status(400).send({ error: "email_required" });
return;
}
const lead = await db.intake_leads.create({
name,
email,
company: data.company,
role_title: data.role_title,
goal: data.goal,
challenge: data.challenge,
desired_outcome: data.desired_outcome,
source: data.source || "website",
consent_ai_notes: Boolean(data.consent_ai_notes),
status: "new",
});
res.status(200).send(lead);
}),
);
module.exports = router;

View File

@ -4,6 +4,7 @@ import {
mdiBookOpenVariant,
mdiClose,
mdiFileDocumentEditOutline,
mdiFormTextbox,
mdiLogout,
mdiMenu,
mdiShieldAccountOutline,
@ -39,6 +40,11 @@ const navItems = [
icon: mdiFileDocumentEditOutline,
label: 'Session Memory',
},
{
href: '/intake-leads',
icon: mdiFormTextbox,
label: 'Intake Leads',
},
{
href: '/client-portal',
icon: mdiBookOpenVariant,

View File

@ -149,7 +149,7 @@ function Nav() {
<a href='#control'>Trust</a>
</nav>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
@ -440,7 +440,7 @@ export default function HowItWorks() {
</p>
<div className='mt-9 flex flex-wrap justify-center gap-4'>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-8 py-4 font-semibold ${ui.button}`}
>
Start free
@ -624,7 +624,7 @@ export default function HowItWorks() {
client portal, and approved follow-up prompts.
</p>
<Link
href='/register/'
href='/intake/'
className={`mt-8 inline-flex rounded-full px-7 py-4 font-semibold ${ui.button}`}
>
Start free
@ -663,7 +663,7 @@ export default function HowItWorks() {
</h2>
<div className='mt-8 flex justify-center'>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-8 py-4 font-semibold ${ui.button}`}
>
Start free

View File

@ -179,7 +179,7 @@ export default function Starter() {
<a href='#pricing'>Packages</a>
</nav>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
@ -207,7 +207,7 @@ export default function Starter() {
</p>
<div className='mt-9 flex justify-center'>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-10 py-5 text-lg font-semibold ${ui.button}`}
>
Start free
@ -503,7 +503,7 @@ export default function Starter() {
</h2>
</div>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-6 py-3 text-center font-semibold ${ui.button}`}
>
Start your workspace
@ -559,7 +559,7 @@ export default function Starter() {
))}
</div>
<Link
href='/register/'
href='/intake/'
className={`mt-8 inline-flex rounded-full px-7 py-4 font-semibold ${ui.button}`}
>
Create workspace

View File

@ -0,0 +1,205 @@
import {
mdiAccountConvertOutline,
mdiArchiveOutline,
mdiEmailOutline,
mdiFormTextbox,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React from 'react';
import type { ReactElement } from 'react';
import BaseIcon from '../components/BaseIcon';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
type IntakeLead = {
id: string;
name: string;
email: string;
company?: string;
role_title?: string;
goal?: string;
challenge?: string;
desired_outcome?: string;
status: string;
consent_ai_notes?: boolean;
};
function Panel({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
export default function IntakeLeads() {
const [leads, setLeads] = React.useState<IntakeLead[]>([]);
const [updatingLeadId, setUpdatingLeadId] = React.useState('');
async function loadLeads() {
const response = await axios.get('/coaching/intake-leads');
setLeads(response.data);
}
React.useEffect(() => {
loadLeads();
}, []);
async function convertLead(leadId: string) {
setUpdatingLeadId(leadId);
await axios.post(`/coaching/intake-leads/${leadId}/convert`);
await loadLeads();
setUpdatingLeadId('');
}
async function archiveLead(leadId: string) {
setUpdatingLeadId(leadId);
const response = await axios.patch(`/coaching/intake-leads/${leadId}/status`, {
status: 'archived',
});
setLeads((current) =>
current.map((lead) => {
if (lead.id === leadId) {
return response.data;
}
return lead;
}),
);
setUpdatingLeadId('');
}
return (
<>
<Head>
<title>{getPageTitle('Intake Leads')}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<BaseIcon path={mdiFormTextbox} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Intake
</span>
</div>
<h1 className='mt-3 text-xl font-semibold'>Intake leads</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
Review website submissions and convert good fits into client
records.
</p>
</div>
<div className='grid gap-4'>
{leads.map((lead) => (
<Panel key={lead.id} className='p-5'>
<div className='flex flex-col justify-between gap-4 lg:flex-row lg:items-start'>
<div>
<div className='flex flex-wrap items-center gap-3'>
<h2 className='text-lg font-semibold text-[#19192d]'>
{lead.name}
</h2>
<span className='rounded-full bg-[#fbf8f1] px-3 py-1 text-xs font-semibold text-[#b17a1e]'>
{lead.status}
</span>
</div>
<p className='mt-1 text-sm text-[#72798a]'>
{lead.role_title || 'Role not provided'} ·{' '}
{lead.company || 'Company not provided'}
</p>
<a
href={`mailto:${lead.email}`}
className='mt-3 inline-flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'
>
<BaseIcon path={mdiEmailOutline} size={18} />
{lead.email}
</a>
</div>
<div className='flex flex-wrap gap-2'>
{lead.status !== 'converted' && (
<button
type='button'
disabled={updatingLeadId === lead.id}
onClick={() => convertLead(lead.id)}
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
>
<BaseIcon path={mdiAccountConvertOutline} size={18} />
Convert to client
</button>
)}
{lead.status !== 'archived' && (
<button
type='button'
disabled={updatingLeadId === lead.id}
onClick={() => archiveLead(lead.id)}
className='inline-flex items-center gap-2 rounded-full border border-[#19192d]/10 bg-white px-3 py-1.5 text-sm font-semibold text-[#19192d] disabled:opacity-50'
>
<BaseIcon path={mdiArchiveOutline} size={18} />
Archive
</button>
)}
</div>
</div>
<div className='mt-5 grid gap-4 lg:grid-cols-3'>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Goal
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{lead.goal || 'No goal provided.'}
</p>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Challenge
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{lead.challenge || 'No challenge provided.'}
</p>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Desired outcome
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{lead.desired_outcome || 'No outcome provided.'}
</p>
</div>
</div>
</Panel>
))}
{leads.length === 0 && (
<Panel className='p-5'>
<p className='text-sm text-[#72798a]'>
No intake leads yet. Share the public intake page from{' '}
<Link href='/intake' className='font-semibold text-[#35b7a5]'>
/intake
</Link>
.
</p>
</Panel>
)}
</div>
</div>
</SectionMain>
</>
);
}
IntakeLeads.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};

View File

@ -0,0 +1,231 @@
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React from 'react';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const fieldClass =
'mt-2 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';
type IntakeValues = {
name: string;
email: string;
company: string;
role_title: string;
goal: string;
challenge: string;
desired_outcome: string;
consent_ai_notes: boolean;
};
const emptyValues: IntakeValues = {
name: '',
email: '',
company: '',
role_title: '',
goal: '',
challenge: '',
desired_outcome: '',
consent_ai_notes: false,
};
function FieldLabel({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className='block'>
<span className='text-sm font-semibold text-[#72798a]'>{label}</span>
{children}
</label>
);
}
export default function Intake() {
const [values, setValues] = React.useState<IntakeValues>(emptyValues);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isSubmitted, setIsSubmitted] = React.useState(false);
function updateValue(field: keyof IntakeValues, value: string | boolean) {
setValues((current) => {
return {
...current,
[field]: value,
};
});
}
async function submitIntake(event: React.FormEvent) {
event.preventDefault();
setIsSubmitting(true);
await axios.post('/coaching-public/intake', {
...values,
source: 'website',
});
setIsSubmitted(true);
setIsSubmitting(false);
}
return (
<>
<Head>
<title>{getPageTitle('Start coaching')}</title>
</Head>
<main className='min-h-screen bg-[#fffdf9] text-[#19192d]'>
<header className='px-5 py-5'>
<div className='mx-auto flex max-w-6xl items-center justify-between'>
<Link href='/' className='font-semibold'>
Coaching SaaS Workspace
</Link>
<Link
href='/login/'
className='rounded-full border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold'
>
Login
</Link>
</div>
</header>
<section className='mx-auto grid max-w-6xl gap-8 px-5 py-10 lg:grid-cols-[0.85fr_1.15fr]'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Coaching intake
</p>
<h1 className='mt-4 font-serif text-5xl font-semibold leading-tight md:text-6xl'>
Start with the coaching context that matters.
</h1>
<p className='mt-5 max-w-xl text-lg leading-8 text-[#72798a]'>
Share what you want to work on. Your coach can review this,
create a client record, and prepare the first session around your
goals.
</p>
</div>
<div className='rounded-lg border border-[#19192d]/10 bg-white p-5'>
{isSubmitted ? (
<div className='rounded-lg bg-[#f3fbf8] p-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Received
</p>
<h2 className='mt-3 text-2xl font-semibold'>
Thanks, we have your intake.
</h2>
<p className='mt-3 leading-7 text-[#72798a]'>
Your coach can now review it and create your client workspace.
</p>
</div>
) : (
<form className='space-y-4' onSubmit={submitIntake}>
<div className='grid gap-4 md:grid-cols-2'>
<FieldLabel label='Name'>
<input
required
value={values.name}
onChange={(event) =>
updateValue('name', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
<FieldLabel label='Email'>
<input
required
type='email'
value={values.email}
onChange={(event) =>
updateValue('email', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldLabel label='Company'>
<input
value={values.company}
onChange={(event) =>
updateValue('company', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
<FieldLabel label='Role'>
<input
value={values.role_title}
onChange={(event) =>
updateValue('role_title', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
</div>
<FieldLabel label='What do you want coaching to help with?'>
<textarea
required
rows={4}
value={values.goal}
onChange={(event) => updateValue('goal', event.target.value)}
className={fieldClass}
/>
</FieldLabel>
<FieldLabel label='What feels stuck right now?'>
<textarea
rows={3}
value={values.challenge}
onChange={(event) =>
updateValue('challenge', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
<FieldLabel label='What would a useful first outcome look like?'>
<textarea
rows={3}
value={values.desired_outcome}
onChange={(event) =>
updateValue('desired_outcome', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
<label className='flex gap-3 rounded-lg bg-[#fbf8f1] p-4 text-sm leading-6 text-[#72798a]'>
<input
type='checkbox'
checked={values.consent_ai_notes}
onChange={(event) =>
updateValue('consent_ai_notes', event.target.checked)
}
className='mt-1'
/>
I am comfortable with AI-assisted summaries and prep notes
being used by the coach inside this workspace.
</label>
<button
type='submit'
disabled={isSubmitting}
className='rounded-full bg-[#35b7a5] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50'
>
{isSubmitting ? 'Submitting...' : 'Send intake'}
</button>
</form>
)}
</div>
</section>
</main>
</>
);
}
Intake.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -84,7 +84,7 @@ function Nav() {
<Link href='/#trust'>Trust</Link>
</nav>
<Link
href='/register/'
href='/intake/'
className={`rounded-full px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free