From 2a104834806c8cb86079fd7c71165f15ea8ab1c5 Mon Sep 17 00:00:00 2001
From: Flatlogic Bot
Date: Tue, 9 Jun 2026 14:39:06 +0000
Subject: [PATCH] Add coaching intake lead flow
---
backend/src/index.js | 3 +-
backend/src/routes/coaching.js | 69 ++++++++
backend/src/routes/coachingPublic.js | 41 +++++
frontend/src/layouts/Authenticated.tsx | 6 +
frontend/src/pages/how-it-works.tsx | 8 +-
frontend/src/pages/index.tsx | 8 +-
frontend/src/pages/intake-leads.tsx | 205 ++++++++++++++++++++++
frontend/src/pages/intake.tsx | 231 +++++++++++++++++++++++++
frontend/src/pages/login.tsx | 2 +-
9 files changed, 563 insertions(+), 10 deletions(-)
create mode 100644 backend/src/routes/coachingPublic.js
create mode 100644 frontend/src/pages/intake-leads.tsx
create mode 100644 frontend/src/pages/intake.tsx
diff --git a/backend/src/index.js b/backend/src/index.js
index 051e269..99a723f 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -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');
diff --git a/backend/src/routes/coaching.js b/backend/src/routes/coaching.js
index 47decde..825d69a 100644
--- a/backend/src/routes/coaching.js
+++ b/backend/src/routes/coaching.js
@@ -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) => {
diff --git a/backend/src/routes/coachingPublic.js b/backend/src/routes/coachingPublic.js
new file mode 100644
index 0000000..2201906
--- /dev/null
+++ b/backend/src/routes/coachingPublic.js
@@ -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;
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index fc0c459..e3b4c98 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -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,
diff --git a/frontend/src/pages/how-it-works.tsx b/frontend/src/pages/how-it-works.tsx
index 894ad3b..b211bba 100644
--- a/frontend/src/pages/how-it-works.tsx
+++ b/frontend/src/pages/how-it-works.tsx
@@ -149,7 +149,7 @@ function Nav() {
Trust
Start free
@@ -440,7 +440,7 @@ export default function HowItWorks() {
Start free
@@ -624,7 +624,7 @@ export default function HowItWorks() {
client portal, and approved follow-up prompts.
Start free
@@ -663,7 +663,7 @@ export default function HowItWorks() {
Start free
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 467dc4a..653b97e 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -179,7 +179,7 @@ export default function Starter() {
Packages
Start free
@@ -207,7 +207,7 @@ export default function Starter() {
Start free
@@ -503,7 +503,7 @@ export default function Starter() {
Start your workspace
@@ -559,7 +559,7 @@ export default function Starter() {
))}
Create workspace
diff --git a/frontend/src/pages/intake-leads.tsx b/frontend/src/pages/intake-leads.tsx
new file mode 100644
index 0000000..20c598f
--- /dev/null
+++ b/frontend/src/pages/intake-leads.tsx
@@ -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 (
+
+ );
+}
+
+export default function IntakeLeads() {
+ const [leads, setLeads] = React.useState
([]);
+ 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 (
+ <>
+
+ {getPageTitle('Intake Leads')}
+
+
+
+
+
+
+
+ Intake
+
+
+
Intake leads
+
+ Review website submissions and convert good fits into client
+ records.
+
+
+
+
+ {leads.map((lead) => (
+
+
+
+
+
+ {lead.name}
+
+
+ {lead.status}
+
+
+
+ {lead.role_title || 'Role not provided'} ยท{' '}
+ {lead.company || 'Company not provided'}
+
+
+
+ {lead.email}
+
+
+
+
+ {lead.status !== 'converted' && (
+
+ )}
+ {lead.status !== 'archived' && (
+
+ )}
+
+
+
+
+
+
+ Goal
+
+
+ {lead.goal || 'No goal provided.'}
+
+
+
+
+ Challenge
+
+
+ {lead.challenge || 'No challenge provided.'}
+
+
+
+
+ Desired outcome
+
+
+ {lead.desired_outcome || 'No outcome provided.'}
+
+
+
+
+ ))}
+
+ {leads.length === 0 && (
+
+
+ No intake leads yet. Share the public intake page from{' '}
+
+ /intake
+
+ .
+
+
+ )}
+
+
+
+ >
+ );
+}
+
+IntakeLeads.getLayout = function getLayout(page: ReactElement) {
+ return {page};
+};
diff --git a/frontend/src/pages/intake.tsx b/frontend/src/pages/intake.tsx
new file mode 100644
index 0000000..04d3cde
--- /dev/null
+++ b/frontend/src/pages/intake.tsx
@@ -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 (
+
+ );
+}
+
+export default function Intake() {
+ const [values, setValues] = React.useState(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 (
+ <>
+
+ {getPageTitle('Start coaching')}
+
+
+
+
+
+
+
+ Coaching intake
+
+
+ Start with the coaching context that matters.
+
+
+ Share what you want to work on. Your coach can review this,
+ create a client record, and prepare the first session around your
+ goals.
+
+
+
+
+ {isSubmitted ? (
+
+
+ Received
+
+
+ Thanks, we have your intake.
+
+
+ Your coach can now review it and create your client workspace.
+
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+}
+
+Intake.getLayout = function getLayout(page: React.ReactElement) {
+ return {page};
+};
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 36fc850..74e82f4 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -84,7 +84,7 @@ function Nav() {
Trust
Start free