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 ( +
+ {children} +
+ ); +} + +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 SaaS Workspace + + + Login + +
+
+ +
+
+

+ 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. +

+
+ ) : ( +
+
+ + + updateValue('name', event.target.value) + } + className={fieldClass} + /> + + + + updateValue('email', event.target.value) + } + className={fieldClass} + /> + +
+ +
+ + + updateValue('company', event.target.value) + } + className={fieldClass} + /> + + + + updateValue('role_title', event.target.value) + } + className={fieldClass} + /> + +
+ + +