feat: expand coaching workspace template
This commit is contained in:
parent
449541f6b8
commit
266ae01a7e
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
*/build/
|
||||
backend/public/uploads/
|
||||
|
||||
@ -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");
|
||||
},
|
||||
};
|
||||
@ -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 },
|
||||
},
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
185
frontend/src/pages/about.tsx
Normal file
185
frontend/src/pages/about.tsx
Normal 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>;
|
||||
};
|
||||
@ -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]'>
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'>
|
||||
|
||||
194
frontend/src/pages/services.tsx
Normal file
194
frontend/src/pages/services.tsx
Normal 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>;
|
||||
};
|
||||
@ -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>
|
||||
|
||||
414
frontend/src/pages/session-memory/view.tsx
Normal file
414
frontend/src/pages/session-memory/view.tsx
Normal 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;
|
||||
1
frontend/src/pages/start-session.tsx
Normal file
1
frontend/src/pages/start-session.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './session-memory';
|
||||
Loading…
x
Reference in New Issue
Block a user