feat: expand coaching workspace template

This commit is contained in:
Flatlogic Bot 2026-06-11 11:17:17 +00:00
parent 449541f6b8
commit 266ae01a7e
14 changed files with 2235 additions and 163 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules/
*/node_modules/
*/build/
backend/public/uploads/

View File

@ -0,0 +1,26 @@
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("sessions", "audio_url", {
type: Sequelize.DataTypes.TEXT,
});
await queryInterface.addColumn("sessions", "audio_filename", {
type: Sequelize.DataTypes.TEXT,
});
await queryInterface.addColumn("sessions", "audio_mime_type", {
type: Sequelize.DataTypes.TEXT,
});
await queryInterface.addColumn("sessions", "audio_size", {
type: Sequelize.DataTypes.INTEGER,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("sessions", "audio_size");
await queryInterface.removeColumn("sessions", "audio_mime_type");
await queryInterface.removeColumn("sessions", "audio_filename");
await queryInterface.removeColumn("sessions", "audio_url");
},
};

View File

@ -19,6 +19,10 @@ module.exports = function(sequelize, DataTypes) {
next_session_prep: { type: DataTypes.TEXT },
private_coach_notes: { type: DataTypes.TEXT },
shared_client_notes: { type: DataTypes.TEXT },
audio_url: { type: DataTypes.TEXT },
audio_filename: { type: DataTypes.TEXT },
audio_mime_type: { type: DataTypes.TEXT },
audio_size: { type: DataTypes.INTEGER },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{

View File

@ -1,12 +1,25 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const os = require("os");
const childProcess = require("child_process");
const util = require("util");
const formidable = require("formidable");
const db = require("../db/models");
const wrapAsync = require("../helpers").wrapAsync;
const { LocalAIApi } = require("../ai/LocalAIApi");
const AuthService = require("../services/auth");
const EmailSender = require("../services/email");
const router = express.Router();
const execFile = util.promisify(childProcess.execFile);
const directAudioUploadLimitBytes = 24 * 1024 * 1024;
const transcriptionChunkSeconds = Number(process.env.AI_TRANSCRIPTION_CHUNK_SECONDS || 180);
const transcriptionJobRetentionMs = 60 * 60 * 1000;
const transcriptionJobs = new Map();
const sessionAudioUploadPath = "/uploads/coaching-sessions";
const sessionAudioUploadDir = path.join(__dirname, "../../public", sessionAudioUploadPath);
function isClientUser(req) {
return req.currentUser?.app_role?.name === "Client";
@ -55,6 +68,65 @@ function clientIncludes() {
];
}
function requestBaseUrl(req) {
const origin = req.get("origin");
if (origin) {
return origin;
}
const referer = req.get("referer");
if (referer) {
const url = new URL(referer);
return `${url.protocol}//${url.host}`;
}
return `${req.protocol}://${req.get("host")}`;
}
async function ensureClientPortalUser(email, currentUserId) {
const normalizedEmail = String(email || "").trim().toLowerCase();
const clientRole = await db.roles.findOne({ where: { name: "Client" } });
if (!clientRole) {
throw new Error("Client role does not exist");
}
let user = await db.users.findOne({
where: { email: normalizedEmail },
include: [{ model: db.roles, as: "app_role" }],
});
if (user && user.app_role && user.app_role.name !== "Client") {
const message = `User ${normalizedEmail} already has ${user.app_role.name} role`;
const error = new Error(message);
error.code = 400;
throw error;
}
if (!user) {
user = await db.users.create({
firstName: normalizedEmail.split("@")[0],
email: normalizedEmail,
disabled: false,
emailVerified: true,
createdById: currentUserId,
updatedById: currentUserId,
});
} else {
await user.update({
disabled: false,
emailVerified: true,
updatedById: currentUserId,
});
}
await user.setApp_role(clientRole.id);
return user;
}
function getFirstUploadedFile(files, fieldName) {
const file = files[fieldName];
@ -70,7 +142,7 @@ function parseAudioUpload(req) {
const form = new formidable.IncomingForm({
multiples: false,
keepExtensions: true,
maxFileSize: 100 * 1024 * 1024,
maxFileSize: 200 * 1024 * 1024,
});
form.parse(req, (error, _fields, files) => {
@ -92,36 +164,166 @@ async function removeUploadedAudio(filePath) {
}
}
async function transcribeAudioFile(audioFile) {
function safeAudioExtension(fileName, mimeType) {
const extension = path.extname(String(fileName || "")).toLowerCase();
if (extension) {
return extension;
}
if (mimeType === "audio/webm") {
return ".webm";
}
if (mimeType === "audio/mpeg") {
return ".mp3";
}
if (mimeType === "audio/wav") {
return ".wav";
}
if (mimeType === "audio/mp4") {
return ".m4a";
}
return ".audio";
}
function publicAudioFilePath(audioUrl) {
if (!audioUrl || !String(audioUrl).startsWith(`${sessionAudioUploadPath}/`)) {
return null;
}
return path.join(__dirname, "../../public", audioUrl.replace(/^\//, ""));
}
async function storeSessionAudioFile(audioFile) {
const filePath = audioFile.filepath || audioFile.path;
const fileName = audioFile.originalFilename || audioFile.name || path.basename(filePath);
const mimeType = audioFile.mimetype || audioFile.type || "application/octet-stream";
const transcriptionUrl =
process.env.AI_TRANSCRIPTION_URL || "https://api.openai.com/v1/audio/transcriptions";
const transcriptionModel = process.env.AI_TRANSCRIPTION_MODEL || "gpt-4o-mini-transcribe";
const transcriptionApiKey = process.env.AI_TRANSCRIPTION_API_KEY || process.env.OPENAI_API_KEY;
const extension = safeAudioExtension(fileName, mimeType);
const storedFileName = `${crypto.randomUUID()}${extension}`;
const storedFilePath = path.join(sessionAudioUploadDir, storedFileName);
if (!filePath) {
throw new Error("Uploaded audio file does not have a readable path");
}
if (!transcriptionApiKey && !process.env.AI_TRANSCRIPTION_URL) {
return {
status: 501,
body: {
error: "transcription_not_configured",
message:
"Set AI_TRANSCRIPTION_URL for the AppWizzy proxy or AI_TRANSCRIPTION_API_KEY/OPENAI_API_KEY for direct transcription.",
},
};
await fs.promises.mkdir(sessionAudioUploadDir, { recursive: true });
await fs.promises.copyFile(filePath, storedFilePath);
return {
audio_url: `${sessionAudioUploadPath}/${storedFileName}`,
audio_filename: fileName,
audio_mime_type: mimeType,
audio_size: audioFile.size || null,
};
}
async function removeSessionAudioFile(audioUrl) {
const filePath = publicAudioFilePath(audioUrl);
if (!filePath) {
return;
}
const audioBuffer = await fs.promises.readFile(filePath);
const formData = new FormData();
formData.append("file", new Blob([audioBuffer], { type: mimeType }), fileName);
formData.append("model", transcriptionModel);
formData.append("response_format", "json");
await fs.promises.rm(filePath, { force: true });
}
function pruneTranscriptionJobs() {
const cutoff = Date.now() - transcriptionJobRetentionMs;
transcriptionJobs.forEach((job, jobId) => {
if (job.updated_at < cutoff) {
transcriptionJobs.delete(jobId);
}
});
}
function updateTranscriptionJob(jobId, data) {
const existingJob = transcriptionJobs.get(jobId) || {};
transcriptionJobs.set(jobId, {
...existingJob,
...data,
updated_at: Date.now(),
});
}
async function runTranscriptionJob(jobId, audioFile, audioData) {
try {
updateTranscriptionJob(jobId, {
status: "processing",
message: "Starting audio transcription.",
});
const result = await transcribeAudioFile(audioFile, (progress) => {
updateTranscriptionJob(jobId, {
status: "processing",
...progress,
});
});
if (result.status !== 200) {
updateTranscriptionJob(jobId, {
status: "failed",
error: result.body,
message:
result.body?.message ||
result.body?.error?.message ||
result.body?.error ||
"Audio transcription failed.",
});
return;
}
updateTranscriptionJob(jobId, {
status: "completed",
message: "Audio transcription completed.",
result: {
...result.body,
...audioData,
},
});
} catch (error) {
updateTranscriptionJob(jobId, {
status: "failed",
message: error.message || "Audio transcription failed.",
});
}
}
function shouldUseDiarizedTranscription(model) {
return model === "gpt-4o-transcribe-diarize";
}
function formatDiarizedTranscript(segments) {
return segments
.map((segment) => {
const speaker = segment.speaker || "Speaker";
const text = String(segment.text || "").trim();
if (!text) {
return "";
}
return `${speaker}: ${text}`;
})
.filter(Boolean)
.join("\n");
}
function transcriptionConfig() {
return {
transcriptionUrl:
process.env.AI_TRANSCRIPTION_URL || "https://api.openai.com/v1/audio/transcriptions",
transcriptionModel: process.env.AI_TRANSCRIPTION_MODEL || "gpt-4o-transcribe-diarize",
transcriptionApiKey: process.env.AI_TRANSCRIPTION_API_KEY || process.env.OPENAI_API_KEY,
};
}
function transcriptionHeaders(transcriptionApiKey) {
const headers = {};
if (process.env.PROJECT_UUID) {
@ -133,9 +335,34 @@ async function transcribeAudioFile(audioFile) {
headers.Authorization = `Bearer ${transcriptionApiKey}`;
}
return headers;
}
async function sendTranscriptionRequest({
filePath,
fileName,
mimeType,
transcriptionUrl,
transcriptionModel,
transcriptionApiKey,
}) {
const audioBuffer = await fs.promises.readFile(filePath);
const formData = new FormData();
const useDiarizedTranscription = shouldUseDiarizedTranscription(transcriptionModel);
formData.append("file", new Blob([audioBuffer], { type: mimeType }), fileName);
formData.append("model", transcriptionModel);
if (useDiarizedTranscription) {
formData.append("response_format", "diarized_json");
formData.append("chunking_strategy", "auto");
} else {
formData.append("response_format", "json");
}
const response = await fetch(transcriptionUrl, {
method: "POST",
headers,
headers: transcriptionHeaders(transcriptionApiKey),
body: formData,
});
const responseText = await response.text();
@ -144,7 +371,17 @@ async function transcribeAudioFile(audioFile) {
try {
payload = JSON.parse(responseText);
} catch {
throw new Error(`Transcription response is not JSON: ${responseText}`);
if (!response.ok) {
return {
status: response.status,
body: {
error: "transcription_provider_non_json_response",
message: `Transcription provider returned HTTP ${response.status}. The request probably timed out before the provider returned JSON.`,
},
};
}
throw new Error(`Transcription response is not JSON: ${responseText.slice(0, 500)}`);
}
if (!response.ok) {
@ -154,7 +391,10 @@ async function transcribeAudioFile(audioFile) {
};
}
const text = payload.text || payload.transcript || payload.output_text;
const diarizedText = Array.isArray(payload.segments)
? formatDiarizedTranscript(payload.segments)
: "";
const text = diarizedText || payload.text || payload.transcript || payload.output_text;
if (!text) {
throw new Error(`Transcription response does not include text: ${JSON.stringify(payload)}`);
@ -162,10 +402,173 @@ async function transcribeAudioFile(audioFile) {
return {
status: 200,
body: { text },
body: {
text,
segments: payload.segments || [],
model: transcriptionModel,
},
};
}
async function createTranscriptionChunks(filePath) {
const chunkDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coaching-audio-"));
const chunkPattern = path.join(chunkDir, "chunk-%03d.mp3");
try {
await execFile(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-i",
filePath,
"-vn",
"-map_metadata",
"-1",
"-ac",
"1",
"-c:a",
"libmp3lame",
"-b:a",
"64k",
"-f",
"segment",
"-segment_time",
String(transcriptionChunkSeconds),
"-reset_timestamps",
"1",
chunkPattern,
],
{ maxBuffer: 1024 * 1024 * 20 },
);
} catch (error) {
await fs.promises.rm(chunkDir, { recursive: true, force: true });
const message = error.stderr || error.message;
throw new Error(`Failed to split audio with ffmpeg: ${message}`);
}
const chunkFiles = (await fs.promises.readdir(chunkDir))
.filter((fileName) => fileName.endsWith(".mp3"))
.sort()
.map((fileName) => path.join(chunkDir, fileName));
if (chunkFiles.length === 0) {
await fs.promises.rm(chunkDir, { recursive: true, force: true });
throw new Error("ffmpeg did not create any audio chunks");
}
return { chunkDir, chunkFiles };
}
async function transcribeLargeAudioFile(filePath, config, onProgress) {
if (onProgress) {
onProgress({ message: "Splitting large audio into smaller parts." });
}
const { chunkDir, chunkFiles } = await createTranscriptionChunks(filePath);
const texts = [];
const segments = [];
if (onProgress) {
onProgress({
message: `Audio split into ${chunkFiles.length} parts. Transcribing part 1 of ${chunkFiles.length}.`,
chunk_index: 1,
chunks_total: chunkFiles.length,
});
}
try {
for (let index = 0; index < chunkFiles.length; index += 1) {
const chunkPath = chunkFiles[index];
if (onProgress) {
onProgress({
message: `Transcribing part ${index + 1} of ${chunkFiles.length}.`,
chunk_index: index + 1,
chunks_total: chunkFiles.length,
});
}
const result = await sendTranscriptionRequest({
filePath: chunkPath,
fileName: path.basename(chunkPath),
mimeType: "audio/mpeg",
...config,
});
if (result.status !== 200) {
return {
status: result.status,
body: {
...result.body,
message:
result.body?.message ||
result.body?.error?.message ||
`Audio transcription failed on part ${index + 1} of ${chunkFiles.length}.`,
chunk_index: index + 1,
chunks_total: chunkFiles.length,
},
};
}
texts.push(result.body.text);
segments.push(...(result.body.segments || []));
}
} finally {
await fs.promises.rm(chunkDir, { recursive: true, force: true });
}
return {
status: 200,
body: {
text: texts.join("\n\n"),
segments,
model: config.transcriptionModel,
transcription_chunks: chunkFiles.length,
},
};
}
async function transcribeAudioFile(audioFile, onProgress) {
const filePath = audioFile.filepath || audioFile.path;
const fileName = audioFile.originalFilename || audioFile.name || path.basename(filePath);
const mimeType = audioFile.mimetype || audioFile.type || "application/octet-stream";
const config = transcriptionConfig();
if (!filePath) {
throw new Error("Uploaded audio file does not have a readable path");
}
if (!config.transcriptionApiKey && !process.env.AI_TRANSCRIPTION_URL) {
return {
status: 501,
body: {
error: "transcription_not_configured",
message:
"Set AI_TRANSCRIPTION_URL for the AppWizzy proxy or AI_TRANSCRIPTION_API_KEY/OPENAI_API_KEY for direct transcription.",
},
};
}
const stats = await fs.promises.stat(filePath);
if (stats.size > directAudioUploadLimitBytes) {
return transcribeLargeAudioFile(filePath, config, onProgress);
}
if (onProgress) {
onProgress({ message: "Transcribing audio." });
}
return sendTranscriptionRequest({
filePath,
fileName,
mimeType,
...config,
});
}
router.get(
"/summary",
wrapAsync(async (req, res) => {
@ -250,9 +653,16 @@ router.post(
return;
}
if (lead.status === "converted") {
res.status(409).send({ error: "lead_already_converted" });
return;
}
const portalUser = await ensureClientPortalUser(lead.email, req.currentUser.id);
const client = await db.clients.create({
name: lead.name,
email: lead.email,
email: portalUser.email,
company: lead.company,
role_title: lead.role_title,
status: "active",
@ -270,12 +680,30 @@ router.post(
updatedById: req.currentUser.id,
});
const inviteWasSent = EmailSender.isConfigured;
if (inviteWasSent) {
await AuthService.sendPasswordResetEmail(
portalUser.email,
"invitation",
requestBaseUrl(req),
);
}
await lead.update({
status: "converted",
updatedById: req.currentUser.id,
});
res.status(200).send({ lead, client });
res.status(200).send({
lead,
client,
portalUser: {
id: portalUser.id,
email: portalUser.email,
},
invite_sent: inviteWasSent,
});
}),
);
@ -458,6 +886,10 @@ router.post(
next_session_prep: data.next_session_prep,
private_coach_notes: data.private_coach_notes,
shared_client_notes: data.shared_client_notes,
audio_url: data.audio_url,
audio_filename: data.audio_filename,
audio_mime_type: data.audio_mime_type,
audio_size: data.audio_size,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
@ -502,6 +934,10 @@ router.post(
next_session_prep: data.next_session_prep,
private_coach_notes: data.private_coach_notes,
shared_client_notes: data.shared_client_notes,
audio_url: data.audio_url,
audio_filename: data.audio_filename,
audio_mime_type: data.audio_mime_type,
audio_size: data.audio_size,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
@ -543,6 +979,59 @@ router.post(
}),
);
router.get(
"/sessions/:id",
wrapAsync(async (req, res) => {
const session = await db.sessions.findByPk(req.params.id, {
include: [{ model: db.clients, as: "client" }],
});
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
res.status(200).send(session);
}),
);
router.get(
"/sessions/:id/audio",
wrapAsync(async (req, res) => {
const session = await db.sessions.findByPk(req.params.id, {
include: [{ model: db.clients, as: "client" }],
});
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
if (isClientUser(req) && session.client?.email !== req.currentUser.email) {
res.status(404).send({ error: "session_not_found" });
return;
}
const filePath = publicAudioFilePath(session.audio_url);
if (!filePath) {
res.status(404).send({ error: "audio_not_found" });
return;
}
const stat = await fs.promises.stat(filePath);
res.setHeader("Content-Type", session.audio_mime_type || "audio/webm");
res.setHeader("Content-Length", stat.size);
res.setHeader(
"Content-Disposition",
`inline; filename="${session.audio_filename || "session-audio.webm"}"`,
);
fs.createReadStream(filePath).pipe(res);
}),
);
router.patch(
"/sessions/:id/share",
wrapAsync(async (req, res) => {
@ -567,6 +1056,30 @@ router.patch(
}),
);
router.delete(
"/sessions/:id",
wrapAsync(async (req, res) => {
if (isClientUser(req)) {
res.status(403).send({ error: "coach_only" });
return;
}
const session = await db.sessions.findByPk(req.params.id);
if (!session) {
res.status(404).send({ error: "session_not_found" });
return;
}
await db.action_items.destroy({ where: { sessionId: session.id } });
await db.prep_briefs.destroy({ where: { sessionId: session.id } });
await removeSessionAudioFile(session.audio_url);
await session.destroy();
res.status(200).send({ deleted: true, id: req.params.id });
}),
);
router.post(
"/session-memory/generate",
wrapAsync(async (req, res) => {
@ -582,12 +1095,7 @@ router.post(
return;
}
const client = await db.clients.findByPk(clientId, {
include: [
{ model: db.sessions, as: "sessions", limit: 5, order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", limit: 10, order: [["due_at", "ASC"]] },
],
});
const client = await db.clients.findByPk(clientId);
if (!client) {
res.status(404).send({ error: "client_not_found" });
@ -605,6 +1113,9 @@ router.post(
text: [
"You are a coaching operations assistant.",
"Extract structured session memory for a professional coaching workspace.",
"Use the transcript as the only source for new session facts.",
"Do not copy or invent prior client history, seed data, or example content.",
"If the transcript does not contain a useful value for a field, return an empty string for that field.",
"Return strict JSON only with these string fields:",
"title, ai_summary, key_topics, goals_discussed, blockers, commitments, homework, emotional_themes, important_quotes, follow_up_email, next_session_prep, private_coach_notes, shared_client_notes.",
].join(" "),
@ -622,10 +1133,7 @@ router.post(
company: client.company,
role_title: client.role_title,
goals: client.goals,
notes: client.notes,
},
recent_sessions: client.sessions || [],
open_action_items: client.action_items || [],
transcript,
}),
},
@ -672,6 +1180,8 @@ router.post(
router.post(
"/session-memory/transcribe",
wrapAsync(async (req, res) => {
pruneTranscriptionJobs();
const audioFile = await parseAudioUpload(req);
if (!audioFile) {
@ -679,14 +1189,52 @@ router.post(
return;
}
const filePath = audioFile.filepath || audioFile.path;
const uploadedFilePath = audioFile.filepath || audioFile.path;
const audioData = await storeSessionAudioFile(audioFile);
const storedFilePath = publicAudioFilePath(audioData.audio_url);
const jobId = crypto.randomUUID();
try {
const result = await transcribeAudioFile(audioFile);
res.status(result.status).send(result.body);
} finally {
await removeUploadedAudio(filePath);
if (!storedFilePath) {
throw new Error("Stored audio file does not have a readable path");
}
updateTranscriptionJob(jobId, {
status: "queued",
message: "Audio uploaded. Waiting to start transcription.",
});
res.status(202).send({
job_id: jobId,
status: "queued",
message: "Audio uploaded. Transcription started in the background.",
});
await removeUploadedAudio(uploadedFilePath);
const storedAudioFile = {
filepath: storedFilePath,
originalFilename: audioData.audio_filename,
mimetype: audioData.audio_mime_type,
size: audioData.audio_size,
};
setImmediate(() => {
runTranscriptionJob(jobId, storedAudioFile, audioData);
});
}),
);
router.get(
"/session-memory/transcribe/:jobId",
wrapAsync(async (req, res) => {
const job = transcriptionJobs.get(req.params.jobId);
if (!job) {
res.status(404).send({ error: "transcription_job_not_found" });
return;
}
res.status(200).send(job);
}),
);

View File

@ -0,0 +1,185 @@
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const ui = {
page: 'bg-[#fffdf9] text-[#19192d]',
banner: 'bg-[#19192d] text-white',
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
ink: 'text-[#19192d]',
muted: 'text-[#72798a]',
accent: 'text-[#35b7a5]',
border: 'border-[#19192d]/10',
surface: 'bg-white',
softSurface: 'bg-[#fffdf9]',
darkPanel: 'bg-[#19192d] text-white',
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
card: 'rounded-none border border-[#19192d]/10 bg-white',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
};
const credentials = [
'Founder and executive coaching',
'Leadership transitions',
'Decision systems and operating rhythm',
'Confidential client workspace',
];
const principles = [
[
'Still human',
'AI helps with memory, prep, and follow-up. The coaching judgment stays with the coach.',
],
[
'Between-session continuity',
'The work does not disappear when the call ends. Notes, commitments, and resources stay connected.',
],
[
'Private by default',
'Private coach notes stay private. Clients see only approved notes, commitments, and resources.',
],
];
function Nav() {
return (
<header className='sticky top-3 z-50 px-5 pt-5'>
<div
className={`mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2.5 backdrop-blur-xl md:px-5 md:py-3 ${ui.navShell}`}
>
<Link href='/' className='flex items-center gap-3 text-lg font-semibold'>
<span className='flex h-9 w-9 items-center justify-center rounded-none bg-[#fffdf9] text-xl font-black text-[#35b7a5]'>
C
</span>
<span className='sr-only'>Coaching Workspace</span>
</Link>
<nav className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}>
<Link href='/how-it-works/'>How it works</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/client-portal/'>Client login</Link>
</nav>
<Link href='/intake/' className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}>
Start assessment
</Link>
</div>
</header>
);
}
export default function AboutCoach() {
return (
<>
<Head>
<title>{getPageTitle('About Coach')}</title>
</Head>
<main className={`min-h-screen ${ui.page}`}>
<div className={`${ui.banner} px-5 py-4 text-center text-lg`}>
<p className='text-xs font-bold uppercase tracking-[0.34em]'>
Coaching practice
</p>
<p className='mt-2 text-xl font-medium md:text-2xl'>
A public coach profile connected to a private client workspace.
</p>
</div>
<Nav />
<section className={`${ui.section} grid gap-12 lg:grid-cols-[0.9fr_1.1fr] lg:items-center`}>
<div>
<p className={ui.overline}>About the coach</p>
<h1 className={`${ui.heading} mt-5 text-5xl leading-tight md:text-6xl`}>
Coaching for founders and senior leaders who are carrying the room.
</h1>
<p className={`mt-7 text-xl leading-8 ${ui.muted}`}>
Use this page to introduce the coach, their niche, credentials,
and point of view. The template is built for practices where trust,
confidentiality, and continuity matter as much as booking the first call.
</p>
<div className='mt-9 flex flex-wrap gap-3'>
<Link href='/intake/' className={`rounded-none px-7 py-4 font-semibold ${ui.button}`}>
Start assessment
</Link>
<Link
href='/services/'
className='rounded-none border border-[#19192d]/10 bg-white px-7 py-4 font-semibold text-[#19192d]'
>
View services
</Link>
</div>
</div>
<div className={`${ui.card} p-6`}>
<div className='border-b border-[#19192d]/10 pb-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Coach profile
</p>
<h2 className='mt-3 text-3xl font-semibold'>Alex Morgan</h2>
<p className={`mt-2 text-lg ${ui.muted}`}>
Executive coach for founders, operators, and leadership teams.
</p>
</div>
<div className='mt-6 grid gap-3'>
{credentials.map((item) => (
<div key={item} className='flex items-center gap-3 rounded-none bg-[#fffdf9] p-4'>
<span className='flex h-7 w-7 items-center justify-center rounded-none bg-[#35b7a5] text-sm font-semibold text-white'>
</span>
<span className='font-medium'>{item}</span>
</div>
))}
</div>
</div>
</section>
<section className={`${ui.section} pt-0`}>
<div className='grid gap-5 md:grid-cols-3'>
{principles.map(([title, copy]) => (
<div key={title} className={`${ui.card} p-6`}>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Principle
</p>
<h3 className='mt-3 text-2xl font-semibold'>{title}</h3>
<p className={`mt-4 leading-7 ${ui.muted}`}>{copy}</p>
</div>
))}
</div>
</section>
<section className={`px-5 py-20 text-center ${ui.darkPanel}`}>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Ready to talk?
</p>
<h2 className='mx-auto mt-4 max-w-3xl font-serif text-5xl font-semibold leading-tight md:text-6xl'>
Start with an assessment and bring context into the first call.
</h2>
<div className='mt-8 flex justify-center'>
<Link href='/intake/' className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}>
Start assessment
</Link>
</div>
</section>
<footer className={`border-t px-5 py-10 ${ui.border}`}>
<div className={`mx-auto flex max-w-7xl flex-col justify-between gap-5 text-sm md:flex-row ${ui.muted}`}>
<p>© 2026 Coaching SaaS Workspace. Coaching beyond the session.</p>
<div className='flex gap-5'>
<Link href='/privacy-policy/'>Privacy Policy</Link>
<Link href='/terms-of-use/'>Terms of Use</Link>
<Link href='/login/'>Login</Link>
</div>
</div>
</footer>
</main>
</>
);
}
AboutCoach.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -5,8 +5,10 @@ import {
mdiContentSaveOutline,
mdiFileDocumentOutline,
mdiLinkVariant,
mdiMicrophoneOutline,
mdiPlus,
mdiTarget,
mdiTrashCanOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
@ -21,6 +23,7 @@ import LayoutAuthenticated from '../../layouts/Authenticated';
type ActionItem = {
id: string;
sessionId?: string;
title: string;
status: string;
due_at?: string;
@ -29,6 +32,7 @@ type ActionItem = {
type PrepBrief = {
id: string;
sessionId?: string;
previous_summary?: string;
open_commitments?: string;
suggested_questions?: string;
@ -47,6 +51,18 @@ type Session = {
id: string;
title: string;
ai_summary?: string;
transcript_notes?: string;
key_topics?: string;
commitments?: string;
homework?: string;
follow_up_email?: string;
next_session_prep?: string;
private_coach_notes?: string;
shared_client_notes?: string;
audio_url?: string;
audio_filename?: string;
audio_mime_type?: string;
audio_size?: number;
session_at?: string;
status?: string;
};
@ -203,6 +219,7 @@ const ClientDetail = () => {
const [newResourceTitle, setNewResourceTitle] = React.useState('');
const [newResourceUrl, setNewResourceUrl] = React.useState('');
const [notice, setNotice] = React.useState('');
const [deletingSessionId, setDeletingSessionId] = React.useState('');
React.useEffect(() => {
if (!router.isReady || typeof router.query.clientId !== 'string') {
@ -287,6 +304,43 @@ const ClientDetail = () => {
setNotice('Resource added and shared with client.');
}
async function deleteSession(session: Session) {
const confirmed = window.confirm(
`Delete "${session.title || 'Client session'}"? This cannot be undone.`,
);
if (!confirmed) {
return;
}
setDeletingSessionId(session.id);
try {
await axios.delete(`/coaching/sessions/${session.id}`);
setClient((currentClient) => {
if (!currentClient) {
return currentClient;
}
return {
...currentClient,
sessions: (currentClient.sessions || []).filter(
(currentSession) => currentSession.id !== session.id,
),
action_items: (currentClient.action_items || []).filter(
(actionItem) => actionItem.sessionId !== session.id,
),
prep_briefs: (currentClient.prep_briefs || []).filter(
(prepBrief) => prepBrief.sessionId !== session.id,
),
};
});
setNotice('Session deleted.');
} finally {
setDeletingSessionId('');
}
}
return (
<>
<Head>
@ -320,9 +374,20 @@ const ClientDetail = () => {
shared resources.
</p>
</div>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
{displayDateTime(client?.next_session_at)}
<div className='flex flex-col gap-3 md:items-end'>
{client && (
<Link
href={`/start-session?clientId=${client.id}`}
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-[#19192d]'
>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
Start session
</Link>
)}
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
{displayDateTime(client?.next_session_at)}
</div>
</div>
</div>
@ -452,6 +517,94 @@ const ClientDetail = () => {
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Session timeline
</h3>
</div>
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-[#19192d]/10 text-left text-sm'>
<thead className='bg-[#fffdf9] text-xs font-semibold uppercase tracking-[0.16em] text-[#72798a]'>
<tr>
<th className='px-5 py-3'>Session</th>
<th className='px-5 py-3'>Date</th>
<th className='px-5 py-3'>Audio</th>
<th className='px-5 py-3'>Status</th>
<th className='px-5 py-3 text-right'>Actions</th>
</tr>
</thead>
<tbody className='divide-y divide-[#19192d]/10'>
{(client.sessions || []).length > 0 ? (
(client.sessions || []).map((session) => (
<tr key={session.id} className='align-top'>
<td className='px-5 py-4'>
<p className='font-semibold text-[#19192d]'>
{session.title || 'Client session'}
</p>
<p className='mt-1 max-w-md truncate text-sm text-[#72798a]'>
{session.ai_summary || 'No coach summary yet.'}
</p>
</td>
<td className='px-5 py-4 text-[#72798a]'>
{displayDateTime(session.session_at)}
</td>
<td className='px-5 py-4 text-[#72798a]'>
{session.audio_url ? 'Saved' : 'None'}
</td>
<td className='px-5 py-4 text-[#72798a]'>
{session.status || 'completed'}
</td>
<td className='px-5 py-4'>
<div className='flex flex-wrap justify-end gap-2'>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-3 py-2 text-sm font-semibold text-[#19192d]'
onClick={() =>
router.push(
`/session-memory/view?sessionId=${session.id}`,
)
}
>
<BaseIcon
path={mdiFileDocumentOutline}
size={18}
/>
Open
</button>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-3 py-2 text-sm font-semibold text-[#19192d] disabled:opacity-50'
disabled={deletingSessionId === session.id}
onClick={() => deleteSession(session)}
>
<BaseIcon
path={mdiTrashCanOutline}
size={18}
/>
{deletingSessionId === session.id
? 'Deleting...'
: 'Delete'}
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className='px-5 py-8 text-center text-sm text-[#72798a]'
>
No saved session memories yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
@ -621,33 +774,6 @@ const ClientDetail = () => {
</div>
<div className='grid gap-6 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Session timeline
</h3>
</div>
<div className='space-y-4 p-7'>
{(client.sessions || []).length > 0 ? (
(client.sessions || []).map((session) => (
<div
key={session.id}
className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'
>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
{session.ai_summary}
</p>
</div>
))
) : (
<EmptyState label='No saved session memories yet.' />
)}
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>

View File

@ -4,11 +4,13 @@ import {
mdiCheckCircleOutline,
mdiClockOutline,
mdiFileDocumentEditOutline,
mdiMicrophoneOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import type { ReactElement } from 'react';
import BaseIcon from '../components/BaseIcon';
@ -89,18 +91,39 @@ function ShellCard({
}
const Dashboard = () => {
const router = useRouter();
const [summary, setSummary] = React.useState<Summary>(emptySummary);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
async function loadSummary() {
const response = await axios.get('/coaching/summary');
setSummary(response.data);
setLoading(false);
const token = localStorage.getItem('token');
if (!token) {
setLoading(false);
router.push('/login');
return;
}
try {
const response = await axios.get('/coaching/summary');
setSummary(response.data);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
router.push('/login');
return;
}
throw error;
} finally {
setLoading(false);
}
}
loadSummary();
}, []);
}, [router]);
const nextPrepBrief = summary.upcomingPrepBriefs[0];
const stats = [
@ -155,10 +178,11 @@ const Dashboard = () => {
</p>
<div className='mt-5 flex flex-wrap gap-3'>
<Link
href='/session-memory'
className='rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-[#19192d]'
href='/start-session'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-5 py-3 text-base font-semibold text-[#19192d]'
>
Generate session memory
<BaseIcon path={mdiMicrophoneOutline} size={20} />
Start client session
</Link>
<Link
href='/clients'
@ -187,7 +211,7 @@ const Dashboard = () => {
nextPrepBrief.previous_summary}
</p>
<Link
href={`/clients?clientId=${nextPrepBrief.client?.id}`}
href={`/clients/view?clientId=${nextPrepBrief.client?.id}`}
className='mt-4 inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
>
Open prep
@ -250,7 +274,7 @@ const Dashboard = () => {
{summary.activeClients.map((client) => (
<Link
key={client.id}
href={`/clients?clientId=${client.id}`}
href={`/clients/view?clientId=${client.id}`}
className='block p-7 transition hover:bg-[#fffdf9]'
>
<div className='flex items-start justify-between gap-6'>
@ -331,7 +355,7 @@ const Dashboard = () => {
{summary.upcomingPrepBriefs.map((brief) => (
<Link
key={brief.id}
href={`/clients?clientId=${brief.client?.id}`}
href={`/clients/view?clientId=${brief.client?.id}`}
className='block p-7 transition hover:bg-[#fffdf9]'
>
<div className='flex items-start justify-between gap-6'>

View File

@ -140,8 +140,9 @@ function Nav() {
className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}
>
<Link href='/how-it-works/'>How it works</Link>
<Link href='/client-portal/'>Coachee</Link>
<a href='#pricing'>Pricing</a>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/client-portal/'>Client login</Link>
<a href='#client-experience'>Compare</a>
<a href='#control'>Trust</a>
</nav>
@ -149,7 +150,7 @@ function Nav() {
href='/intake/'
className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
</div>
</header>
@ -440,7 +441,7 @@ export default function HowItWorks() {
href='/intake/'
className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
<a
href='#coaching-week'
@ -676,6 +677,8 @@ export default function HowItWorks() {
<div className='flex gap-5'>
<Link href='/privacy-policy/'>Privacy Policy</Link>
<Link href='/terms-of-use/'>Terms of Use</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/login/'>Login</Link>
</div>
</div>

View File

@ -168,18 +168,18 @@ export default function Starter() {
className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}
>
<Link href='/how-it-works/'>How it works</Link>
<Link href='/client-portal/'>Coachee</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/client-portal/'>Client login</Link>
<a href='#pricing'>Pricing</a>
<a href='#session-memory'>Compare</a>
<a href='#trust'>Trust</a>
<a href='#events'>Events</a>
<a href='#pricing'>Packages</a>
</nav>
<Link
href='/intake/'
className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
</div>
</header>
@ -207,7 +207,7 @@ export default function Starter() {
href='/intake/'
className={`rounded-none px-10 py-5 text-lg font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
</div>
<p className={`mt-5 text-lg ${ui.muted}`}>
@ -572,6 +572,8 @@ export default function Starter() {
<div className='flex gap-5'>
<Link href='/privacy-policy/'>Privacy Policy</Link>
<Link href='/terms-of-use/'>Terms of Use</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/login/'>Login</Link>
</div>
</div>

View File

@ -46,6 +46,7 @@ function Panel({
export default function IntakeLeads() {
const [leads, setLeads] = React.useState<IntakeLead[]>([]);
const [updatingLeadId, setUpdatingLeadId] = React.useState('');
const [notice, setNotice] = React.useState('');
async function loadLeads() {
const response = await axios.get('/coaching/intake-leads');
@ -58,13 +59,27 @@ export default function IntakeLeads() {
async function convertLead(leadId: string) {
setUpdatingLeadId(leadId);
await axios.post(`/coaching/intake-leads/${leadId}/convert`);
await loadLeads();
setUpdatingLeadId('');
setNotice('');
try {
const response = await axios.post(`/coaching/intake-leads/${leadId}/convert`);
await loadLeads();
if (response.data.invite_sent) {
setNotice('Client workspace created. Invitation email was sent.');
} else {
setNotice('Client workspace created. Email is not configured, so no invitation email was sent.');
}
} catch (error: any) {
setNotice(error.response?.data || 'Could not convert intake lead.');
} finally {
setUpdatingLeadId('');
}
}
async function archiveLead(leadId: string) {
setUpdatingLeadId(leadId);
setNotice('');
const response = await axios.patch(
`/coaching/intake-leads/${leadId}/status`,
{
@ -104,6 +119,12 @@ export default function IntakeLeads() {
</p>
</div>
{notice && (
<div className='mb-4 rounded-none border border-[#19192d]/10 bg-white p-4 text-sm font-semibold text-[#19192d]'>
{notice}
</div>
)}
<div className='grid gap-6'>
{leads.map((lead) => (
<Panel key={lead.id} className='p-5'>

View File

@ -0,0 +1,194 @@
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const ui = {
page: 'bg-[#fffdf9] text-[#19192d]',
banner: 'bg-[#19192d] text-white',
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
ink: 'text-[#19192d]',
muted: 'text-[#72798a]',
accent: 'text-[#35b7a5]',
border: 'border-[#19192d]/10',
surface: 'bg-white',
darkPanel: 'bg-[#19192d] text-white',
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
card: 'rounded-none border border-[#19192d]/10 bg-white',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
};
const packages = [
{
name: 'Leadership Assessment',
price: 'Intro',
copy: 'A focused first step for founders and senior leaders who want to clarify the coaching agenda.',
items: ['intake review', 'goals and pressure points', 'recommended coaching plan'],
},
{
name: 'Founder Coaching',
price: 'Monthly',
copy: 'Ongoing 1:1 coaching with session memory, commitments, resources, and between-session accountability.',
items: ['two sessions per month', 'shared client portal', 'coach-approved follow-up'],
},
{
name: 'Executive Operating Rhythm',
price: 'Custom',
copy: 'A deeper engagement for leaders navigating delegation, decision rights, and team operating cadence.',
items: ['leadership themes', 'decision-system work', 'next-session prep briefs'],
},
];
const outcomes = [
'Clearer decisions',
'Better delegation boundaries',
'Stronger leadership presence',
'Follow-through between sessions',
'A private place for client resources',
'Less admin after every session',
];
function Nav() {
return (
<header className='sticky top-3 z-50 px-5 pt-5'>
<div
className={`mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2.5 backdrop-blur-xl md:px-5 md:py-3 ${ui.navShell}`}
>
<Link href='/' className='flex items-center gap-3 text-lg font-semibold'>
<span className='flex h-9 w-9 items-center justify-center rounded-none bg-[#fffdf9] text-xl font-black text-[#35b7a5]'>
C
</span>
<span className='sr-only'>Coaching Workspace</span>
</Link>
<nav className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}>
<Link href='/how-it-works/'>How it works</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/client-portal/'>Client login</Link>
</nav>
<Link href='/intake/' className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}>
Start assessment
</Link>
</div>
</header>
);
}
export default function Services() {
return (
<>
<Head>
<title>{getPageTitle('Coaching Services')}</title>
</Head>
<main className={`min-h-screen ${ui.page}`}>
<div className={`${ui.banner} px-5 py-4 text-center text-lg`}>
<p className='text-xs font-bold uppercase tracking-[0.34em]'>
Services and packages
</p>
<p className='mt-2 text-xl font-medium md:text-2xl'>
Sell coaching clearly, then manage the whole relationship in one workspace.
</p>
</div>
<Nav />
<section className={`${ui.section} text-center`}>
<p className={ui.overline}>Coaching offers</p>
<h1 className={`${ui.heading} mx-auto mt-5 max-w-4xl text-5xl leading-tight md:text-6xl`}>
Packages for leaders who need clarity, accountability, and continuity.
</h1>
<p className={`mx-auto mt-7 max-w-3xl text-xl leading-8 ${ui.muted}`}>
Use these cards as starter offers. A coach can rename packages,
change pricing, and route every CTA into the intake flow.
</p>
</section>
<section className={`${ui.section} pt-0`}>
<div className='grid gap-5 lg:grid-cols-3'>
{packages.map((item) => (
<div key={item.name} className={`${ui.card} flex flex-col p-6`}>
<div className='border-b border-[#19192d]/10 pb-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
{item.price}
</p>
<h2 className='mt-3 text-3xl font-semibold'>{item.name}</h2>
<p className={`mt-4 leading-7 ${ui.muted}`}>{item.copy}</p>
</div>
<div className='mt-5 grid gap-3'>
{item.items.map((included) => (
<div key={included} className='flex items-center gap-3'>
<span className='flex h-6 w-6 items-center justify-center rounded-none bg-[#35b7a5] text-xs font-semibold text-white'>
</span>
<span className='font-medium'>{included}</span>
</div>
))}
</div>
<Link href='/intake/' className={`mt-7 inline-flex justify-center rounded-none px-6 py-4 font-semibold ${ui.button}`}>
Start intake
</Link>
</div>
))}
</div>
</section>
<section className={`${ui.section} pt-0`}>
<div className={`${ui.card} grid gap-8 p-7 lg:grid-cols-[0.9fr_1.1fr] lg:items-center`}>
<div>
<p className={ui.overline}>Expected outcomes</p>
<h2 className={`${ui.heading} mt-4 text-4xl leading-tight md:text-5xl`}>
Make the value of coaching concrete before the first call.
</h2>
<p className={`mt-5 text-lg leading-8 ${ui.muted}`}>
FounderCoaching-style public sites make outcomes visible. This
template connects those outcomes to the working system behind
the scenes: clients, sessions, notes, resources, and follow-up.
</p>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
{outcomes.map((item) => (
<div key={item} className='rounded-none bg-[#fffdf9] p-4 font-semibold'>
{item}
</div>
))}
</div>
</div>
</section>
<section className={`px-5 py-20 text-center ${ui.darkPanel}`}>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Next step
</p>
<h2 className='mx-auto mt-4 max-w-3xl font-serif text-5xl font-semibold leading-tight md:text-6xl'>
Start with intake, then convert the lead into a client workspace.
</h2>
<div className='mt-8 flex justify-center'>
<Link href='/intake/' className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}>
Start assessment
</Link>
</div>
</section>
<footer className={`border-t px-5 py-10 ${ui.border}`}>
<div className={`mx-auto flex max-w-7xl flex-col justify-between gap-5 text-sm md:flex-row ${ui.muted}`}>
<p>© 2026 Coaching SaaS Workspace. Coaching beyond the session.</p>
<div className='flex gap-5'>
<Link href='/privacy-policy/'>Privacy Policy</Link>
<Link href='/terms-of-use/'>Terms of Use</Link>
<Link href='/login/'>Login</Link>
</div>
</div>
</footer>
</main>
</>
);
}
Services.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -7,12 +7,13 @@ import {
mdiMicrophoneOutline,
mdiStopCircleOutline,
mdiSendOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
@ -43,8 +44,49 @@ type Session = MemoryDraft & {
id: string;
status?: string;
client?: Client;
session_at?: string;
transcript_notes?: string;
audio_url?: string;
audio_filename?: string;
audio_mime_type?: string;
audio_size?: number;
};
type AudioAttachment = {
audio_url: string;
audio_filename?: string;
audio_mime_type?: string;
audio_size?: number;
};
type TranscriptionResult = AudioAttachment & {
text?: string;
transcription_chunks?: number;
};
type TranscriptionJob = {
status: 'queued' | 'processing' | 'completed' | 'failed';
message?: string;
chunk_index?: number;
chunks_total?: number;
result?: TranscriptionResult;
error?: unknown;
};
type TranscriptionProgress = {
current: number;
total: number;
};
type TranscriptTurn = {
speaker: string;
text: string;
};
const MAX_AUDIO_FILE_BYTES = 200 * 1024 * 1024;
const DIRECT_TRANSCRIPTION_FILE_BYTES = 24 * 1024 * 1024;
const ACTIVE_TRANSCRIPTION_JOB_KEY = 'coaching-active-transcription-job-id';
function Panel({
children,
className = '',
@ -101,6 +143,20 @@ function StatusPill({ status }: { status?: string }) {
);
}
function formatFileSize(bytes: number) {
return `${Math.round(bytes / 1024 / 1024)} MB`;
}
function audioTooLargeMessage() {
return `Audio file is too large. Maximum supported size is ${formatFileSize(
MAX_AUDIO_FILE_BYTES,
)}.`;
}
function wait(milliseconds: number) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function requestErrorMessage(error: unknown, fallback: string) {
if (!axios.isAxiosError(error)) {
return fallback;
@ -108,6 +164,10 @@ function requestErrorMessage(error: unknown, fallback: string) {
const data = error.response?.data;
if (error.response?.status === 413 || data?.error === 'payload_too_large') {
return audioTooLargeMessage();
}
if (typeof data?.message === 'string') {
return data.message;
}
@ -123,6 +183,97 @@ function requestErrorMessage(error: unknown, fallback: string) {
return fallback;
}
function parseTranscriptTurns(value?: string) {
const turns: TranscriptTurn[] = [];
String(value || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const match = line.match(/^([A-ZА-Я][\wА-Яа-я -]{0,30}):\s*(.+)$/u);
if (match) {
turns.push({
speaker: match[1],
text: match[2],
});
return;
}
const previousTurn = turns[turns.length - 1];
if (previousTurn) {
previousTurn.text = `${previousTurn.text} ${line}`;
return;
}
turns.push({
speaker: 'Transcript',
text: line,
});
});
return turns;
}
function TranscriptConversation({
value,
maxTurns,
}: {
value?: string;
maxTurns?: number;
}) {
const turns = parseTranscriptTurns(value);
const visibleTurns = maxTurns ? turns.slice(0, maxTurns) : turns;
if (turns.length === 0) {
return (
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-4 text-sm text-[#72798a]'>
No transcript saved for this session.
</p>
);
}
return (
<div className='space-y-3'>
{visibleTurns.map((turn, index) => (
<div
key={`${turn.speaker}-${index}-${turn.text.slice(0, 18)}`}
className='grid gap-3 border border-[#19192d]/10 bg-[#fffdf9] p-4 md:grid-cols-[72px_1fr]'
>
<div className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{turn.speaker}
</div>
<p className='text-sm leading-6 text-[#19192d]'>{turn.text}</p>
</div>
))}
{maxTurns && turns.length > maxTurns && (
<p className='text-sm font-semibold text-[#72798a]'>
{turns.length - maxTurns} more transcript turns
</p>
)}
</div>
);
}
function displayDateTime(value?: string) {
if (!value) {
return 'No date';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
const emptyDraft: MemoryDraft = {
title: '',
ai_summary: '',
@ -140,23 +291,31 @@ const emptyDraft: MemoryDraft = {
};
const SessionMemory = () => {
const router = useRouter();
const [clients, setClients] = React.useState<Client[]>([]);
const [sessions, setSessions] = React.useState<Session[]>([]);
const [clientId, setClientId] = React.useState('');
const [transcript, setTranscript] = React.useState('');
const [draft, setDraft] = React.useState<MemoryDraft>(emptyDraft);
const [audioFile, setAudioFile] = React.useState<File | null>(null);
const [audioAttachment, setAudioAttachment] =
React.useState<AudioAttachment | null>(null);
const [isRecording, setIsRecording] = React.useState(false);
const [recordingSeconds, setRecordingSeconds] = React.useState(0);
const [isGenerating, setIsGenerating] = React.useState(false);
const [isTranscribing, setIsTranscribing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [deletingSessionId, setDeletingSessionId] = React.useState('');
const [notice, setNotice] = React.useState('');
const [transcriptionNotice, setTranscriptionNotice] = React.useState('');
const [transcriptionProgress, setTranscriptionProgress] =
React.useState<TranscriptionProgress | null>(null);
const [lastSavedSession, setLastSavedSession] =
React.useState<Session | null>(null);
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null);
const mediaStreamRef = React.useRef<MediaStream | null>(null);
const audioChunksRef = React.useRef<Blob[]>([]);
const resumedTranscriptionJobRef = React.useRef(false);
async function loadData() {
const [clientsResponse, sessionsResponse] = await Promise.all([
@ -166,14 +325,29 @@ const SessionMemory = () => {
setClients(clientsResponse.data);
setSessions(sessionsResponse.data);
const requestedClientId =
typeof router.query.clientId === 'string' ? router.query.clientId : '';
const requestedClient = clientsResponse.data.find((client: Client) => {
return client.id === requestedClientId;
});
if (!clientId && requestedClient) {
setClientId(requestedClient.id);
return;
}
if (!clientId && clientsResponse.data.length > 0) {
setClientId(clientsResponse.data[0].id);
}
}
React.useEffect(() => {
if (!router.isReady) {
return;
}
loadData();
}, []);
}, [router.isReady, router.query.clientId]);
React.useEffect(() => {
if (!isRecording) {
@ -195,6 +369,43 @@ const SessionMemory = () => {
};
}, []);
React.useEffect(() => {
if (resumedTranscriptionJobRef.current) {
return;
}
const jobId = window.localStorage.getItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
if (!jobId) {
return;
}
resumedTranscriptionJobRef.current = true;
setIsTranscribing(true);
setTranscriptionNotice('Restoring background transcription status.');
setTranscriptionProgress(null);
pollTranscriptionJob(jobId)
.catch((error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
window.localStorage.removeItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
setTranscriptionNotice('Previous transcription job is no longer available. Please upload the audio again.');
setTranscriptionProgress(null);
return;
}
if (error instanceof Error) {
setTranscriptionNotice(error.message);
return;
}
setTranscriptionNotice(requestErrorMessage(error, 'Audio transcription failed.'));
})
.finally(() => {
setIsTranscribing(false);
});
}, []);
function updateDraft(field: keyof MemoryDraft, value: string) {
setDraft((current) => {
return {
@ -227,9 +438,75 @@ const SessionMemory = () => {
}
}
function applyTranscriptionResult(result: TranscriptionResult) {
setTranscript(result.text || '');
setAudioAttachment({
audio_url: result.audio_url,
audio_filename: result.audio_filename,
audio_mime_type: result.audio_mime_type,
audio_size: result.audio_size,
});
setTranscriptionProgress(null);
if (result.transcription_chunks && result.transcription_chunks > 1) {
setTranscriptionNotice(
'Audio transcribed and saved. Review the transcript before generating memory.',
);
setNotice('');
} else {
setTranscriptionNotice(
'Audio transcribed and saved. Review the transcript before generating memory.',
);
setNotice('');
}
}
async function pollTranscriptionJob(jobId: string) {
while (true) {
await wait(2500);
const response = await axios.get<TranscriptionJob>(
`/coaching/session-memory/transcribe/${jobId}`,
);
const job = response.data;
if (job.chunk_index && job.chunks_total) {
setTranscriptionProgress({
current: job.chunk_index,
total: job.chunks_total,
});
setTranscriptionNotice('Transcribing audio.');
} else if (job.status === 'processing') {
setTranscriptionNotice('Preparing audio transcription.');
} else if (job.message) {
setTranscriptionNotice(job.message);
}
if (job.status === 'completed') {
if (!job.result) {
throw new Error('Transcription job completed without a result.');
}
window.localStorage.removeItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
applyTranscriptionResult(job.result);
return;
}
if (job.status === 'failed') {
window.localStorage.removeItem(ACTIVE_TRANSCRIPTION_JOB_KEY);
throw new Error(job.message || 'Audio transcription failed.');
}
}
}
async function transcribeAudio() {
if (!audioFile) {
setNotice('Choose an audio file first.');
setTranscriptionNotice('Choose an audio file first.');
return;
}
if (audioFile.size > MAX_AUDIO_FILE_BYTES) {
setTranscriptionNotice(audioTooLargeMessage());
return;
}
@ -237,6 +514,14 @@ const SessionMemory = () => {
formData.append('audio', audioFile);
setIsTranscribing(true);
if (audioFile.size > DIRECT_TRANSCRIPTION_FILE_BYTES) {
setTranscriptionProgress(null);
setTranscriptionNotice('Large audio detected. Uploading it before background transcription.');
} else {
setTranscriptionProgress(null);
setTranscriptionNotice('Uploading audio before background transcription.');
}
try {
const response = await axios.post(
'/coaching/session-memory/transcribe',
@ -245,12 +530,26 @@ const SessionMemory = () => {
headers: { 'Content-Type': 'multipart/form-data' },
},
);
setTranscript(response.data.text || '');
setNotice(
'Audio transcribed. Review the transcript before generating memory.',
);
if (response.status === 202 && response.data.job_id) {
window.localStorage.setItem(
ACTIVE_TRANSCRIPTION_JOB_KEY,
response.data.job_id,
);
setTranscriptionNotice(response.data.message || 'Audio uploaded. Transcription started.');
await pollTranscriptionJob(response.data.job_id);
return;
}
applyTranscriptionResult(response.data);
} catch (error) {
setNotice(requestErrorMessage(error, 'Audio transcription failed.'));
setTranscriptionProgress(null);
if (error instanceof Error) {
setTranscriptionNotice(error.message);
} else {
setTranscriptionNotice(requestErrorMessage(error, 'Audio transcription failed.'));
}
} finally {
setIsTranscribing(false);
}
@ -281,12 +580,12 @@ const SessionMemory = () => {
async function startRecording() {
if (typeof MediaRecorder === 'undefined') {
setNotice('Audio recording is not supported in this browser.');
setTranscriptionNotice('Audio recording is not supported in this browser.');
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setNotice('Audio recording is not supported in this browser.');
setTranscriptionNotice('Audio recording is not supported in this browser.');
return;
}
@ -295,7 +594,7 @@ const SessionMemory = () => {
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
setNotice('Microphone access was blocked or failed.');
setTranscriptionNotice('Microphone access was blocked or failed.');
return;
}
@ -321,13 +620,16 @@ const SessionMemory = () => {
});
setAudioFile(file);
setNotice('Recording saved. Transcribe it when you are ready.');
setAudioAttachment(null);
setTranscriptionNotice('Recording saved. Transcribe it when you are ready.');
setIsRecording(false);
stopAudioTracks();
};
setRecordingSeconds(0);
setAudioFile(null);
setAudioAttachment(null);
setTranscriptionNotice('');
setNotice('');
setIsRecording(true);
recorder.start();
@ -356,8 +658,15 @@ const SessionMemory = () => {
setIsSaving(true);
const response = await axios.post('/coaching/session-memory/save', {
...draft,
title:
draft.title ||
`Session with ${selectedClient?.name || 'client'} ${new Date().toLocaleDateString()}`,
clientId,
transcript_notes: transcript,
audio_url: audioAttachment?.audio_url,
audio_filename: audioAttachment?.audio_filename,
audio_mime_type: audioAttachment?.audio_mime_type,
audio_size: audioAttachment?.audio_size,
shareWithClient,
});
@ -365,6 +674,8 @@ const SessionMemory = () => {
setLastSavedSession(response.data);
setDraft(emptyDraft);
setTranscript('');
setAudioFile(null);
setAudioAttachment(null);
setNotice(
shareWithClient
? 'Session saved and shared with the client.'
@ -393,6 +704,32 @@ const SessionMemory = () => {
setNotice('Session shared with the client.');
}
async function deleteSession(session: Session) {
const confirmed = window.confirm(
`Delete "${session.title || 'Client session'}"? This cannot be undone.`,
);
if (!confirmed) {
return;
}
setDeletingSessionId(session.id);
try {
await axios.delete(`/coaching/sessions/${session.id}`);
setSessions((current) =>
current.filter((currentSession) => currentSession.id !== session.id),
);
if (lastSavedSession?.id === session.id) {
setLastSavedSession(null);
}
setNotice('Session deleted.');
} finally {
setDeletingSessionId('');
}
}
async function copyFollowUp() {
await navigator.clipboard.writeText(draft.follow_up_email || '');
setNotice('Follow-up copied.');
@ -405,11 +742,158 @@ const SessionMemory = () => {
draft.shared_client_notes,
);
const selectedClient = clients.find((client) => client.id === clientId);
const isStartSessionPage = router.pathname === '/start-session';
const pageTitle = isStartSessionPage ? 'Start Client Session' : 'Session Memory';
if (!isStartSessionPage) {
return (
<>
<Head>
<title>{getPageTitle(pageTitle)}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 flex flex-col justify-between gap-4 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
<div>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Session Memory
</span>
</div>
<h1 className='mt-3 max-w-3xl text-xl font-semibold'>
Saved client sessions
</h1>
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
Review transcripts, coach notes, sharing status, and saved
session memories.
</p>
</div>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-[#19192d]'
onClick={() => router.push('/start-session')}
>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
Start new session
</button>
</div>
{notice && (
<div className='mb-4 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] px-4 py-3 text-sm font-semibold text-[#35b7a5]'>
{notice}
</div>
)}
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h2 className='text-lg font-semibold text-[#19192d]'>
Session archive
</h2>
</div>
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-[#19192d]/10 text-left text-sm'>
<thead className='bg-[#fffdf9] text-xs font-semibold uppercase tracking-[0.16em] text-[#72798a]'>
<tr>
<th className='px-5 py-3'>Session</th>
<th className='px-5 py-3'>Client</th>
<th className='px-5 py-3'>Date</th>
<th className='px-5 py-3'>Audio</th>
<th className='px-5 py-3'>Status</th>
<th className='px-5 py-3 text-right'>Actions</th>
</tr>
</thead>
<tbody className='divide-y divide-[#19192d]/10'>
{sessions.map((session) => (
<React.Fragment key={session.id}>
<tr className='align-top'>
<td className='px-5 py-4'>
<p className='font-semibold text-[#19192d]'>
{session.title || 'Client session'}
</p>
<p className='mt-1 max-w-lg truncate text-sm text-[#72798a]'>
{session.ai_summary || 'No coach summary yet.'}
</p>
</td>
<td className='px-5 py-4 text-[#19192d]'>
{session.client?.name || 'No client'}
</td>
<td className='px-5 py-4 text-[#72798a]'>
{displayDateTime(session.session_at)}
</td>
<td className='px-5 py-4 text-[#72798a]'>
{session.audio_url ? 'Saved' : 'None'}
</td>
<td className='px-5 py-4'>
<StatusPill status={session.status} />
</td>
<td className='px-5 py-4'>
<div className='flex flex-wrap justify-end gap-2'>
<button
type='button'
className='inline-flex items-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-3 py-2 text-sm font-semibold text-[#19192d]'
onClick={() =>
router.push(
`/session-memory/view?sessionId=${session.id}`,
)
}
>
<BaseIcon
path={mdiFileDocumentEditOutline}
size={18}
/>
Open
</button>
{session.status !== 'shared' && (
<button
type='button'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-3 py-2 text-sm font-semibold text-white'
onClick={() => shareSession(session)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Share
</button>
)}
<button
type='button'
className='inline-flex items-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-3 py-2 text-sm font-semibold text-[#19192d] disabled:opacity-50'
disabled={deletingSessionId === session.id}
onClick={() => deleteSession(session)}
>
<BaseIcon path={mdiTrashCanOutline} size={18} />
{deletingSessionId === session.id
? 'Deleting...'
: 'Delete'}
</button>
</div>
</td>
</tr>
</React.Fragment>
))}
{sessions.length === 0 && (
<tr>
<td
colSpan={6}
className='px-5 py-8 text-center text-sm text-[#72798a]'
>
No saved sessions yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Panel>
</div>
</SectionMain>
</>
);
}
return (
<>
<Head>
<title>{getPageTitle('Session Memory')}</title>
<title>{getPageTitle(pageTitle)}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
@ -417,16 +901,18 @@ const SessionMemory = () => {
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Session Memory
{isStartSessionPage ? 'Start session' : 'Session Memory'}
</span>
</div>
<h1 className='mt-3 max-w-3xl text-xl font-semibold'>
Turn rough notes into follow-up, commitments, and next-session
prep.
{isStartSessionPage
? 'Record or upload a client session, then turn it into memory.'
: 'Turn rough notes into follow-up, commitments, and next-session prep.'}
</h1>
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
Generate a structured draft, edit it as the coach, save it as a
private draft, or share approved notes with the client portal.
{isStartSessionPage
? 'Choose the client, capture audio or upload a file, transcribe it, and generate the coach review.'
: 'Generate a structured draft, edit it as the coach, save it as a private draft, or share approved notes with the client portal.'}
</p>
</div>
@ -442,7 +928,7 @@ const SessionMemory = () => {
Raw session input
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Extract a session
{isStartSessionPage ? 'Capture this session' : 'Extract a session'}
</h2>
<label className='mb-2 mt-6 block text-sm font-semibold text-[#72798a]'>
@ -513,7 +999,18 @@ const SessionMemory = () => {
className='hidden'
onChange={(event) => {
const file = event.target.files?.[0] || null;
setAudioAttachment(null);
if (file && file.size > MAX_AUDIO_FILE_BYTES) {
setAudioFile(null);
setTranscriptionNotice(audioTooLargeMessage());
event.target.value = '';
return;
}
setAudioFile(file);
setTranscriptionNotice('');
setTranscriptionProgress(null);
setNotice('');
}}
/>
@ -527,6 +1024,46 @@ const SessionMemory = () => {
<BaseIcon path={mdiMicrophoneOutline} size={18} />
{isTranscribing ? 'Transcribing...' : 'Transcribe audio'}
</button>
{(transcriptionNotice || transcriptionProgress) && (
<div className='mt-3 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] px-4 py-3 text-sm font-semibold text-[#35b7a5]'>
{transcriptionProgress && (
<div className='mb-2 h-2 w-full overflow-hidden rounded-none bg-[#35b7a5]/15'>
<div
className='h-full rounded-none bg-[#35b7a5] transition-[width] duration-300'
style={{
width: `${Math.min(
100,
Math.max(
4,
Math.round(
(transcriptionProgress.current /
transcriptionProgress.total) *
100,
),
),
)}%`,
}}
/>
</div>
)}
{transcriptionNotice}
</div>
)}
{audioAttachment?.audio_url && (
<div className='mt-3 rounded-none border border-[#19192d]/10 bg-white p-4'>
<p className='text-sm font-semibold text-[#19192d]'>
Audio saved for this session
</p>
<audio
className='mt-3 w-full'
controls
src={audioAttachment.audio_url}
/>
<p className='mt-2 text-xs text-[#72798a]'>
{audioAttachment.audio_filename || 'Session audio'}
</p>
</div>
)}
</div>
<label className='mb-2 mt-5 block text-sm font-semibold text-[#72798a]'>
@ -538,17 +1075,45 @@ const SessionMemory = () => {
className='min-h-[220px] w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
placeholder='Paste session transcript, coach notes, commitments, blockers, or a rough debrief...'
/>
<div className='mt-5'>
<BaseButton
label={isGenerating ? 'Generating...' : 'Generate memory'}
color='info'
<div className='mt-5 flex flex-wrap gap-3'>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isGenerating || !clientId || !transcript.trim()}
onClick={generateMemory}
/>
>
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
{isGenerating
? 'Generating review...'
: 'Generate coach review'}
</button>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d] disabled:opacity-50'
disabled={isSaving || !clientId || !transcript.trim()}
onClick={() => saveMemory(false)}
>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
{isSaving ? 'Saving session...' : 'Save session'}
</button>
</div>
</Panel>
<div className='space-y-4'>
{transcript.trim() && (
<Panel className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Session transcript
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Speaker view
</h2>
<div className='mt-4 max-h-[420px] overflow-y-auto'>
<TranscriptConversation value={transcript} />
</div>
</Panel>
)}
<Panel className='p-4'>
<div className='flex items-start justify-between gap-6'>
<div>
@ -639,7 +1204,7 @@ const SessionMemory = () => {
onClick={() => saveMemory(false)}
>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
Save coach draft
Save session draft
</button>
<button
type='button'
@ -648,7 +1213,7 @@ const SessionMemory = () => {
onClick={() => saveMemory(true)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Save and share
Save session and share
</button>
<button
type='button'
@ -695,55 +1260,13 @@ const SessionMemory = () => {
className='text-[#35b7a5]'
/>
<p className='mt-4 leading-6 text-[#72798a]'>
Generate memory from raw notes. The editable draft will
appear here before anything is saved or shared.
Generate a coach review from the transcript. The editable
draft will appear here before anything is saved or shared.
</p>
</div>
)}
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h2 className='text-lg font-semibold text-[#19192d]'>
Recent memories
</h2>
</div>
<div className='divide-y divide-[#19192d]/10'>
{sessions.map((session) => (
<div key={session.id} className='p-5'>
<div className='flex items-start justify-between gap-6'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<StatusPill status={session.status} />
</div>
<p className='mt-1 text-sm text-[#72798a]'>
{session.client?.name}
</p>
</div>
{session.status !== 'shared' && (
<button
type='button'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
onClick={() => shareSession(session)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Share
</button>
)}
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{session.ai_summary}
</p>
<p className='mt-3 text-sm font-semibold text-[#35b7a5]'>
{session.key_topics}
</p>
</div>
))}
</div>
</Panel>
</div>
</div>
</div>

View File

@ -0,0 +1,414 @@
import {
mdiArrowLeft,
mdiCheckCircleOutline,
mdiFileDocumentEditOutline,
mdiSendOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
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 Client = {
id: string;
name: string;
};
type Session = {
id: string;
title?: string;
status?: string;
client?: Client;
session_at?: string;
transcript_notes?: string;
ai_summary?: string;
key_topics?: string;
goals_discussed?: string;
blockers?: string;
commitments?: string;
homework?: string;
emotional_themes?: string;
important_quotes?: string;
follow_up_email?: string;
next_session_prep?: string;
private_coach_notes?: string;
shared_client_notes?: string;
audio_url?: string;
audio_filename?: string;
};
type TranscriptTurn = {
speaker: string;
text: string;
};
function Panel({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
function StatusPill({ status }: { status?: string }) {
const isShared = status === 'shared';
return (
<span
className={`rounded-none px-3 py-1 text-xs font-semibold ${
isShared ? 'bg-[#fffdf9] text-[#35b7a5]' : 'bg-[#fffdf9] text-[#35b7a5]'
}`}
>
{isShared ? 'Shared' : 'Coach draft'}
</span>
);
}
function parseTranscriptTurns(value?: string) {
const turns: TranscriptTurn[] = [];
String(value || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const match = line.match(/^([A-ZА-Я][\wА-Яа-я -]{0,30}):\s*(.+)$/u);
if (match) {
turns.push({
speaker: match[1],
text: match[2],
});
return;
}
const previousTurn = turns[turns.length - 1];
if (previousTurn) {
previousTurn.text = `${previousTurn.text} ${line}`;
return;
}
turns.push({
speaker: 'Transcript',
text: line,
});
});
return turns;
}
function TranscriptConversation({ value }: { value?: string }) {
const turns = parseTranscriptTurns(value);
if (turns.length === 0) {
return (
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-4 text-sm text-[#72798a]'>
No transcript saved for this session.
</p>
);
}
return (
<div className='space-y-3'>
{turns.map((turn, index) => (
<div
key={`${turn.speaker}-${index}-${turn.text.slice(0, 18)}`}
className='grid gap-3 border border-[#19192d]/10 bg-[#fffdf9] p-4 md:grid-cols-[72px_1fr]'
>
<div className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{turn.speaker}
</div>
<p className='text-sm leading-6 text-[#19192d]'>{turn.text}</p>
</div>
))}
</div>
);
}
function displayDateTime(value?: string) {
if (!value) {
return 'No date';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function DetailBlock({ label, value }: { label: string; value?: string }) {
if (!value) {
return null;
}
return (
<div className='border-t border-[#19192d]/10 pt-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{label}
</p>
<p className='mt-2 whitespace-pre-wrap text-sm leading-6 text-[#72798a]'>
{value}
</p>
</div>
);
}
const SessionMemoryView = () => {
const router = useRouter();
const [session, setSession] = React.useState<Session | null>(null);
const [notice, setNotice] = React.useState('');
const [audioSourceUrl, setAudioSourceUrl] = React.useState('');
const [isDeleting, setIsDeleting] = React.useState(false);
const [isSharing, setIsSharing] = React.useState(false);
React.useEffect(() => {
if (!router.isReady || typeof router.query.sessionId !== 'string') {
return;
}
loadSession(router.query.sessionId);
}, [router.isReady, router.query.sessionId]);
React.useEffect(() => {
if (!session?.audio_url) {
setAudioSourceUrl('');
return;
}
let revoked = false;
let objectUrl = '';
axios
.get(`/coaching/sessions/${session.id}/audio`, {
responseType: 'blob',
})
.then((response) => {
objectUrl = URL.createObjectURL(response.data);
if (revoked) {
URL.revokeObjectURL(objectUrl);
return;
}
setAudioSourceUrl(objectUrl);
})
.catch(() => {
setNotice('Audio file could not be loaded.');
});
return () => {
revoked = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [session?.id, session?.audio_url]);
async function loadSession(sessionId: string) {
const response = await axios.get(`/coaching/sessions/${sessionId}`);
setSession(response.data);
}
async function shareSession() {
if (!session) {
return;
}
setIsSharing(true);
try {
const response = await axios.patch(`/coaching/sessions/${session.id}/share`, {
shared_client_notes: session.shared_client_notes,
});
setSession(response.data);
setNotice('Session shared with the client.');
} finally {
setIsSharing(false);
}
}
async function deleteSession() {
if (!session) {
return;
}
const confirmed = window.confirm(
`Delete "${session.title || 'Client session'}"? This cannot be undone.`,
);
if (!confirmed) {
return;
}
setIsDeleting(true);
try {
await axios.delete(`/coaching/sessions/${session.id}`);
await router.push('/session-memory');
} finally {
setIsDeleting(false);
}
}
return (
<>
<Head>
<title>{getPageTitle(session?.title || 'Session')}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<button
type='button'
className='mb-6 inline-flex items-center gap-2 text-sm font-semibold text-[#72798a] hover:text-[#19192d]'
onClick={() => router.push('/session-memory')}
>
<BaseIcon path={mdiArrowLeft} size={18} />
Session archive
</button>
<div className='mb-4 flex flex-col justify-between gap-4 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
<div>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Session
</span>
</div>
<h1 className='mt-3 max-w-3xl text-xl font-semibold'>
{session?.title || 'Client session'}
</h1>
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
{session
? `${session.client?.name || 'No client'} · ${displayDateTime(
session.session_at,
)}`
: 'Loading session...'}
</p>
</div>
{session && <StatusPill status={session.status} />}
</div>
{notice && (
<div className='mb-4 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] px-4 py-3 text-sm font-semibold text-[#35b7a5]'>
{notice}
</div>
)}
{session ? (
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr]'>
<Panel className='p-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Transcript
</p>
<div className='mt-4'>
<TranscriptConversation value={session.transcript_notes} />
</div>
</Panel>
<div className='space-y-6'>
<Panel className='p-5'>
<div className='flex flex-wrap gap-3'>
{session.status !== 'shared' && (
<button
type='button'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSharing}
onClick={shareSession}
>
<BaseIcon path={mdiSendOutline} size={18} />
{isSharing ? 'Sharing...' : 'Share with client'}
</button>
)}
<button
type='button'
className='inline-flex items-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d] disabled:opacity-50'
disabled={isDeleting}
onClick={deleteSession}
>
<BaseIcon path={mdiTrashCanOutline} size={18} />
{isDeleting ? 'Deleting...' : 'Delete session'}
</button>
</div>
</Panel>
{session.audio_url && (
<Panel className='p-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Audio recording
</p>
{audioSourceUrl ? (
<audio
className='mt-4 w-full'
controls
src={audioSourceUrl}
/>
) : (
<p className='mt-4 text-sm text-[#72798a]'>
Loading audio...
</p>
)}
<p className='mt-2 text-xs text-[#72798a]'>
{session.audio_filename || 'Session audio'}
</p>
</Panel>
)}
<Panel className='space-y-4 p-5'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Coach memory
</p>
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
{session.ai_summary || 'No summary saved.'}
</p>
</div>
<DetailBlock label='Key topics' value={session.key_topics} />
<DetailBlock label='Goals discussed' value={session.goals_discussed} />
<DetailBlock label='Blockers' value={session.blockers} />
<DetailBlock label='Commitments' value={session.commitments} />
<DetailBlock label='Homework' value={session.homework} />
<DetailBlock label='Emotional themes' value={session.emotional_themes} />
<DetailBlock label='Important quotes' value={session.important_quotes} />
<DetailBlock label='Shared client notes' value={session.shared_client_notes} />
<DetailBlock label='Follow-up email' value={session.follow_up_email} />
<DetailBlock label='Next-session prep' value={session.next_session_prep} />
<DetailBlock label='Private coach notes' value={session.private_coach_notes} />
</Panel>
</div>
</div>
) : (
<Panel className='p-5'>
<p className='text-sm text-[#72798a]'>Loading session...</p>
</Panel>
)}
</div>
</SectionMain>
</>
);
};
SessionMemoryView.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default SessionMemoryView;

View File

@ -0,0 +1 @@
export { default } from './session-memory';