Compare commits

...

10 Commits

Author SHA1 Message Date
Flatlogic Bot
266ae01a7e feat: expand coaching workspace template 2026-06-11 11:17:17 +00:00
Flatlogic Bot
449541f6b8 Simplify coach sidebar navigation 2026-06-10 09:28:06 +00:00
Flatlogic Bot
2c902aa822 Split clients list and detail views 2026-06-09 18:40:39 +00:00
Flatlogic Bot
cbebf46653 Add coach client management flow 2026-06-09 18:01:43 +00:00
Flatlogic Bot
be3bbea7aa Refine coaching UI and add audio recording 2026-06-09 17:13:18 +00:00
Flatlogic Bot
058b824a5c Clarify session memory save state 2026-06-09 16:31:47 +00:00
Flatlogic Bot
1a1c1a02ce Use billable AI model for session memory 2026-06-09 16:20:20 +00:00
Flatlogic Bot
196c6f48ed Surface transcription proxy errors 2026-06-09 15:35:09 +00:00
Flatlogic Bot
c61345a89d Use project bearer for transcription proxy 2026-06-09 15:23:27 +00:00
Flatlogic Bot
ba861959bd Add coaching audio transcription flow 2026-06-09 14:50:17 +00:00
23 changed files with 3982 additions and 729 deletions

1
.gitignore vendored
View File

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

View File

@ -154,7 +154,7 @@ async function awaitResponse(aiRequestId, options = {}) {
const interval = Math.max(Number(options.interval ?? 5), 1);
const deadline = Date.now() + Math.max(timeout, interval) * 1000;
while (true) {
while (Date.now() < deadline) {
const statusResp = await fetchStatus(aiRequestId, {
headers: options.headers,
timeout: options.timeout_per_call,
@ -184,16 +184,14 @@ async function awaitResponse(aiRequestId, options = {}) {
return statusResp;
}
if (Date.now() >= deadline) {
return {
success: false,
error: "timeout",
message: "Timed out waiting for AI response.",
};
}
await sleep(interval * 1000);
}
return {
success: false,
error: "timeout",
message: "Timed out waiting for AI response.",
};
}
function extractText(response) {
@ -283,7 +281,7 @@ function config() {
projectId,
projectUuid: process.env.PROJECT_UUID || null,
projectHeader: process.env.AI_PROJECT_HEADER || "project-uuid",
defaultModel: process.env.AI_DEFAULT_MODEL || "gpt-5-mini",
defaultModel: process.env.AI_DEFAULT_MODEL || "gpt-5.5",
timeout,
verifyTls,
};

View File

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

View File

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

View File

@ -1,9 +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";
@ -26,6 +42,533 @@ function splitActionItems(value) {
.slice(0, 8);
}
function clientPayload(data, userId) {
return {
name: data.name,
email: data.email,
status: data.status || "active",
goals: data.goals,
notes: data.notes,
company: data.company,
role_title: data.role_title,
tags: data.tags,
next_session_at: data.next_session_at || null,
last_session_at: data.last_session_at || null,
updatedById: userId,
};
}
function clientIncludes() {
return [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] },
{ model: db.resources, as: "resources", order: [["createdAt", "DESC"]] },
{ model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] },
];
}
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];
if (Array.isArray(file)) {
return file[0];
}
return file;
}
function parseAudioUpload(req) {
return new Promise((resolve, reject) => {
const form = new formidable.IncomingForm({
multiples: false,
keepExtensions: true,
maxFileSize: 200 * 1024 * 1024,
});
form.parse(req, (error, _fields, files) => {
if (error) {
reject(error);
return;
}
resolve(getFirstUploadedFile(files, "audio") || getFirstUploadedFile(files, "file"));
});
});
}
async function removeUploadedAudio(filePath) {
try {
await fs.promises.unlink(filePath);
} catch (error) {
console.warn("Failed to remove uploaded audio file", error);
}
}
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 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");
}
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;
}
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) {
headers.Authorization = `Bearer ${process.env.PROJECT_UUID}`;
headers["project-uuid"] = process.env.PROJECT_UUID;
}
if (transcriptionApiKey) {
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: transcriptionHeaders(transcriptionApiKey),
body: formData,
});
const responseText = await response.text();
let payload;
try {
payload = JSON.parse(responseText);
} catch {
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) {
return {
status: response.status,
body: payload,
};
}
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)}`);
}
return {
status: 200,
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) => {
@ -110,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",
@ -130,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,
});
}),
);
@ -156,17 +724,35 @@ router.get(
}),
);
router.post(
"/clients",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
if (!String(data.name || "").trim()) {
res.status(400).send({ error: "client_name_required" });
return;
}
const client = await db.clients.create({
...clientPayload(data, req.currentUser.id),
ownerId: req.currentUser.id,
createdById: req.currentUser.id,
});
const savedClient = await db.clients.findByPk(client.id, {
include: clientIncludes(),
});
res.status(200).send(savedClient);
}),
);
router.get(
"/clients/:id",
wrapAsync(async (req, res) => {
const client = await db.clients.findByPk(req.params.id, {
include: [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] },
{ model: db.resources, as: "resources" },
{ model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] },
],
include: clientIncludes(),
});
if (!client) {
@ -178,6 +764,93 @@ router.get(
}),
);
router.patch(
"/clients/:id",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await db.clients.findByPk(req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.name || "").trim()) {
res.status(400).send({ error: "client_name_required" });
return;
}
await client.update(clientPayload(data, req.currentUser.id));
const savedClient = await db.clients.findByPk(client.id, {
include: clientIncludes(),
});
res.status(200).send(savedClient);
}),
);
router.post(
"/clients/:id/action-items",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await db.clients.findByPk(req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.title || "").trim()) {
res.status(400).send({ error: "action_title_required" });
return;
}
const actionItem = await db.action_items.create({
clientId: client.id,
title: data.title,
due_at: data.due_at || null,
status: data.status || "not_started",
notes: data.notes,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(actionItem);
}),
);
router.post(
"/clients/:id/resources",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await db.clients.findByPk(req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.title || "").trim()) {
res.status(400).send({ error: "resource_title_required" });
return;
}
const resource = await db.resources.create({
clientId: client.id,
title: data.title,
description: data.description,
url: data.url,
resource_type: data.resource_type || "link",
is_shared: data.is_shared !== false,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(resource);
}),
);
router.get(
"/session-memory",
wrapAsync(async (req, res) => {
@ -213,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,
});
@ -257,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,
});
@ -298,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) => {
@ -322,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) => {
@ -337,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" });
@ -360,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(" "),
@ -377,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,
}),
},
@ -424,6 +1177,67 @@ router.post(
}),
);
router.post(
"/session-memory/transcribe",
wrapAsync(async (req, res) => {
pruneTranscriptionJobs();
const audioFile = await parseAudioUpload(req);
if (!audioFile) {
res.status(400).send({ error: "audio_required" });
return;
}
const uploadedFilePath = audioFile.filepath || audioFile.path;
const audioData = await storeSessionAudioFile(audioFile);
const storedFilePath = publicAudioFilePath(audioData.audio_url);
const jobId = crypto.randomUUID();
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);
}),
);
router.get(
"/client-portal/me",
wrapAsync(async (req, res) => {

View File

@ -148,6 +148,10 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
return item.href === '/client-portal' || item.href === '/profile';
}
if (item.href === '/client-portal' || item.href === '/users/users-list') {
return false;
}
if (!item.permission) {
return true;
}
@ -170,11 +174,11 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
>
<div className='flex items-center justify-between'>
<Link href='/dashboard' className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full border border-[#b17a1e]/50 bg-[#35b7a5] text-sm font-black text-white'>
<span className='grid h-8 w-8 place-items-center rounded-none border border-[#35b7a5]/50 bg-[#35b7a5] text-sm font-black text-white'>
CW
</span>
<span>
<span className='block text-sm font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<span className='block text-sm font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
AppWizzy
</span>
<span className='block text-lg font-semibold'>
@ -184,15 +188,15 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
</Link>
<button
type='button'
className='grid h-8 w-8 place-items-center rounded-full border border-[#19192d]/10 text-[#72798a] lg:hidden'
className='grid h-8 w-8 place-items-center rounded-none border border-[#19192d]/10 text-[#72798a] lg:hidden'
onClick={() => setIsAsideOpen(false)}
>
<BaseIcon path={mdiClose} size={18} />
</button>
</div>
<div className='mt-5 rounded-lg border border-[#19192d]/10 bg-white p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#b17a1e]'>
<div className='mt-5 rounded-none border border-[#19192d]/10 bg-white p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#35b7a5]'>
Today
</p>
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
@ -211,10 +215,10 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold transition ${
className={`flex items-center gap-3 rounded-none px-4 py-3 text-sm font-semibold transition ${
isActive
? 'bg-[#35b7a5] text-white'
: 'text-[#72798a] hover:bg-[#fbf8f1] hover:text-[#19192d]'
: 'text-[#72798a] hover:bg-[#fffdf9] hover:text-[#19192d]'
}`}
>
<BaseIcon path={item.icon} size={18} />
@ -224,8 +228,8 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
})}
</nav>
<div className='absolute inset-x-4 bottom-5 rounded-lg border border-[#19192d]/10 bg-[#fbf8f1] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
<div className='absolute inset-x-4 bottom-5 rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Signed in
</p>
<p className='mt-2 truncate text-sm font-semibold'>
@ -233,7 +237,7 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
</p>
<button
type='button'
className='mt-4 flex w-full items-center justify-center gap-2 rounded-full bg-[#19192d] px-3 py-1.5 text-sm font-semibold text-white'
className='mt-4 flex w-full items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white'
onClick={() => {
dispatch(logoutUser());
router.push('/login');
@ -255,17 +259,17 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
)}
<div className='lg:pl-64'>
<header className='sticky top-0 z-20 border-b border-[#19192d]/10/80 bg-[#fffdf9]/92 px-3 py-2 backdrop-blur lg:px-8'>
<header className='sticky top-0 z-20 border-b border-[#19192d]/10/80 bg-[#fffdf9]/92 px-4 py-3 backdrop-blur lg:px-8'>
<div className='flex items-center justify-between'>
<button
type='button'
className='grid h-8 w-8 place-items-center rounded-full border border-[#19192d]/10 bg-white text-[#72798a] lg:hidden'
className='grid h-8 w-8 place-items-center rounded-none border border-[#19192d]/10 bg-white text-[#72798a] lg:hidden'
onClick={() => setIsAsideOpen(true)}
>
<BaseIcon path={mdiMenu} size={18} />
</button>
<div className='hidden lg:block'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Workspace status
</p>
<p className='mt-1 text-sm text-[#72798a]'>
@ -276,7 +280,7 @@ export default function LayoutAuthenticated({ children, permission }: Props) {
</div>
<Link
href='/'
className='rounded-full border border-[#19192d]/10 bg-white px-3 py-1.5 text-sm font-semibold text-[#19192d]'
className='rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
>
Public site
</Link>

View File

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

View File

@ -48,7 +48,7 @@ function Panel({
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
@ -180,9 +180,9 @@ const ClientPortal = () => {
const openItems = (portalClient?.action_items || []).filter((item) => {
return item.status !== 'done';
});
const finishedCount =
(portalClient?.action_items || []).filter((item) => item.status === 'done')
.length;
const finishedCount = (portalClient?.action_items || []).filter(
(item) => item.status === 'done',
).length;
const latestSession = portalClient?.sessions?.[0];
return (
@ -193,14 +193,14 @@ const ClientPortal = () => {
<SectionMain>
<div className='mx-auto max-w-7xl'>
{!isClientUser && (
<Panel className='mb-4 p-4'>
<label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<Panel className='mb-4 p-6'>
<label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Preview as client
</label>
<select
value={clientId}
onChange={(event) => setClientId(event.target.value)}
className='mt-3 w-full rounded-lg border border-[#19192d]/10 bg-white px-3 py-2 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15 md:w-96'
className='mt-3 w-full rounded-none border border-[#19192d]/10 bg-white px-4 py-3 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15 md:w-96'
>
{clients.map((client) => (
<option key={client.id} value={client.id}>
@ -212,11 +212,11 @@ const ClientPortal = () => {
)}
{portalClient && (
<div className='grid gap-4'>
<div className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-5'>
<div className='grid gap-4 lg:grid-cols-[1fr_280px]'>
<div className='grid gap-6'>
<div className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-7'>
<div className='grid gap-6 lg:grid-cols-[1fr_280px]'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Client workspace
</p>
<h1 className='mt-2 text-2xl font-semibold text-[#19192d]'>
@ -229,7 +229,7 @@ const ClientPortal = () => {
</p>
</div>
<div className='rounded-lg border border-[#19192d]/10 bg-white p-4'>
<div className='rounded-none border border-[#19192d]/10 bg-white p-6'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
Next session
@ -244,7 +244,7 @@ const ClientPortal = () => {
</div>
</div>
<div className='grid gap-4 md:grid-cols-3'>
<div className='grid gap-6 md:grid-cols-3'>
<Panel className='p-4'>
<p className='text-sm font-semibold text-[#72798a]'>
Open commitments
@ -271,12 +271,12 @@ const ClientPortal = () => {
</Panel>
</div>
<div className='grid gap-4 xl:grid-cols-[0.95fr_1.05fr]'>
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
<div className='space-y-4'>
<Panel>
<div className='border-b border-[#19192d]/10 p-4'>
<div className='border-b border-[#19192d]/10 p-6'>
<div className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
</span>
<div>
@ -289,25 +289,24 @@ const ClientPortal = () => {
</div>
</div>
</div>
<div className='space-y-3 p-4'>
<div className='space-y-3 p-6'>
{(portalClient.action_items || []).map((item) => {
const isDone =
item.status === 'done';
const isDone = item.status === 'done';
return (
<button
key={item.id}
type='button'
className={`flex w-full gap-3 rounded-lg border p-4 text-left transition ${
className={`flex w-full gap-3 rounded-none border p-6 text-left transition ${
isDone
? 'border-[#35b7a5]/30 bg-[#f3fbf8]'
? 'border-[#35b7a5]/30 bg-[#fffdf9]'
: 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]'
}`}
disabled={updatingItemId === item.id}
onClick={() => toggleItem(item)}
>
<span
className={`mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full border ${
className={`mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none border ${
isDone
? 'border-[#35b7a5] bg-[#35b7a5] text-white'
: 'border-[#19192d]/10 bg-white text-[#72798a]'
@ -336,7 +335,7 @@ const ClientPortal = () => {
<Panel className='p-4'>
<div className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#fbf8f1] text-[#b17a1e]'>
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={mdiPencilOutline} size={18} />
</span>
<div>
@ -354,7 +353,7 @@ const ClientPortal = () => {
setReflection(event.target.value);
setReflectionSaved(false);
}}
className='mt-4 min-h-[140px] w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
className='mt-4 min-h-[140px] w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 text-sm leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
placeholder='What did you complete? What changed? What should we focus on next?'
/>
<div className='mt-3 flex items-center justify-between gap-3'>
@ -365,7 +364,7 @@ const ClientPortal = () => {
</p>
<button
type='button'
className='rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
className='rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={!reflection.trim()}
onClick={saveReflection}
>
@ -377,9 +376,9 @@ const ClientPortal = () => {
<div className='space-y-4'>
<Panel className='overflow-hidden'>
<div className='border-b border-[#19192d]/10 bg-[#fffdf9] p-4'>
<div className='border-b border-[#19192d]/10 bg-[#fffdf9] p-6'>
<div className='flex items-center gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon
path={mdiMessageReplyTextOutline}
size={18}
@ -396,9 +395,9 @@ const ClientPortal = () => {
</div>
</div>
<div className='space-y-3 p-4'>
<div className='space-y-3 p-6'>
{latestSession && (
<div className='rounded-lg border border-[#19192d]/10 bg-white p-4'>
<div className='rounded-none border border-[#19192d]/10 bg-white p-6'>
<p className='font-semibold text-[#19192d]'>
{latestSession.title}
</p>
@ -410,7 +409,7 @@ const ClientPortal = () => {
{(portalClient.sessions || []).slice(1).map((session) => (
<details
key={session.id}
className='rounded-lg border border-[#19192d]/10 bg-white p-4'
className='rounded-none border border-[#19192d]/10 bg-white p-6'
>
<summary className='cursor-pointer font-semibold text-[#19192d]'>
{session.title}
@ -424,20 +423,20 @@ const ClientPortal = () => {
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-4'>
<div className='border-b border-[#19192d]/10 p-6'>
<h2 className='font-semibold text-[#19192d]'>
Resources
</h2>
</div>
<div className='space-y-3 p-4'>
<div className='space-y-3 p-6'>
{(portalClient.resources || []).map((resource) => (
<a
key={resource.id}
href={resource.url}
className='flex items-center justify-between gap-4 rounded-lg border border-[#19192d]/10 bg-white p-4 transition hover:bg-[#fffdf9]'
className='flex items-center justify-between gap-6 rounded-none border border-[#19192d]/10 bg-white p-6 transition hover:bg-[#fffdf9]'
>
<div className='flex gap-3'>
<span className='grid h-8 w-8 place-items-center rounded-full bg-[#fbf8f1] text-[#b17a1e]'>
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={mdiBookOpenVariant} size={18} />
</span>
<div>

View File

@ -1,12 +1,12 @@
import {
mdiAccountGroup,
mdiCalendarClock,
mdiCheckCircleOutline,
mdiFileDocumentOutline,
mdiTarget,
mdiArrowRight,
mdiContentSaveOutline,
mdiPlus,
} 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';
@ -17,46 +17,65 @@ import LayoutAuthenticated from '../layouts/Authenticated';
type ActionItem = {
id: string;
title: string;
status: string;
};
type PrepBrief = {
id: string;
previous_summary?: string;
open_commitments?: string;
suggested_questions?: string;
sensitive_topics?: string;
client_reflection?: string;
client_reflection_at?: string;
};
type Session = {
id: string;
title: string;
ai_summary?: string;
};
type Client = {
id: string;
name: string;
email: string;
status: string;
goals?: string;
notes?: string;
company?: string;
role_title?: string;
tags?: string;
next_session_at?: string;
sessions?: Session[];
action_items?: ActionItem[];
prep_briefs?: PrepBrief[];
package?: {
title?: string;
duration?: string;
};
};
type ClientForm = {
name: string;
email: string;
status: string;
company: string;
role_title: string;
tags: string;
goals: string;
notes: string;
next_session_at: string;
};
function emptyClientForm(): ClientForm {
return {
name: '',
email: '',
status: 'active',
company: '',
role_title: '',
tags: '',
goals: '',
notes: '',
next_session_at: '',
};
}
function displayDateTime(value?: string) {
if (!value) {
return 'Not scheduled';
}
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 Panel({
children,
className = '',
@ -66,81 +85,84 @@ function Panel({
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
function EmptyState({ label }: { label: string }) {
return (
<p className='rounded-lg border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-4 text-sm text-[#72798a]'>
{label}
</p>
);
function inputClass() {
return 'w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15';
}
function PrepText({ value }: { value?: string }) {
if (!value) {
return <EmptyState label='No prep details saved yet.' />;
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className='space-y-2'>
{value
.split(/\n|;/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => (
<p key={item} className='text-sm leading-6 text-[#72798a]'>
{item}
</p>
))}
</div>
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{label}
</span>
<div className='mt-2'>{children}</div>
</label>
);
}
const Clients = () => {
const router = useRouter();
const [clients, setClients] = React.useState<Client[]>([]);
const [selectedClientId, setSelectedClientId] = React.useState<string>('');
const [clientForm, setClientForm] =
React.useState<ClientForm>(emptyClientForm());
const [isCreating, setIsCreating] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [notice, setNotice] = React.useState('');
React.useEffect(() => {
async function loadClients() {
const response = await axios.get('/coaching/clients');
setClients(response.data);
loadClients();
}, []);
const queryClientId = router.query.clientId;
if (typeof queryClientId === 'string') {
setSelectedClientId(queryClientId);
return;
}
async function loadClients() {
const response = await axios.get('/coaching/clients');
setClients(response.data);
}
if (response.data.length > 0) {
setSelectedClientId(response.data[0].id);
}
}
if (router.isReady) {
loadClients();
}
}, [router.isReady]);
const selectedClient =
clients.find((client) => client.id === selectedClientId) || clients[0];
const latestReflection = selectedClient?.prep_briefs?.find((brief) => {
return Boolean(brief.client_reflection);
});
const latestPrepBrief = selectedClient?.prep_briefs?.[0];
function selectClient(clientId: string) {
setSelectedClientId(clientId);
router.replace(`/clients?clientId=${clientId}`, undefined, {
shallow: true,
function updateClientForm(field: keyof ClientForm, value: string) {
setClientForm((current) => {
return {
...current,
[field]: value,
};
});
}
async function createClient() {
setIsSaving(true);
setNotice('');
try {
const response = await axios.post('/coaching/clients', {
...clientForm,
next_session_at: clientForm.next_session_at || null,
});
await loadClients();
setClientForm(emptyClientForm());
setIsCreating(false);
await router.push(`/clients/${response.data.id}`);
} finally {
setIsSaving(false);
}
}
function openCreateForm() {
setIsCreating(true);
setNotice('');
}
return (
<>
<Head>
@ -148,282 +170,204 @@ const Clients = () => {
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 flex flex-col justify-between gap-4 rounded-lg bg-[#19192d] p-5 text-white md:flex-row md:items-end'>
<div className='mb-6 flex flex-col justify-between gap-6 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
<div>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiAccountGroup} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Client CRM
</span>
</div>
<h1 className='mt-3 text-xl font-semibold'>Client records</h1>
<h1 className='mt-3 text-xl font-semibold'>Clients</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
Open a client record to review goals, coach notes, recent
sessions, and commitments.
Scan the practice, add a client, and open a dedicated client
file when you need the full context.
</p>
</div>
<div className='rounded-full bg-white/10 px-3 py-1.5 text-sm font-semibold text-[#fbf8f1]'>
{clients.length} clients
</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-white'
onClick={openCreateForm}
>
<BaseIcon path={mdiPlus} size={18} />
New client
</button>
</div>
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
<Panel className='overflow-hidden'>
<div className='border-b border-[#19192d]/10 p-5'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Coaching relationships
</p>
{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>
)}
{isCreating && (
<Panel className='mb-6 p-7'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
New client
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Create a client record
</h2>
</div>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={createClient}
>
<BaseIcon path={mdiContentSaveOutline} size={18} />
{isSaving ? 'Saving...' : 'Create and open'}
</button>
</div>
<div className='divide-y divide-[#19192d]/10'>
{clients.map((client) => (
<button
key={client.id}
type='button'
onClick={() => selectClient(client.id)}
className={`block w-full p-5 text-left transition ${
selectedClient?.id === client.id
? 'bg-[#f3fbf8]'
: 'bg-white hover:bg-[#fffdf9]'
}`}
<div className='mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4'>
<Field label='Name'>
<input
value={clientForm.name}
onChange={(event) =>
updateClientForm('name', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Email'>
<input
value={clientForm.email}
type='email'
onChange={(event) =>
updateClientForm('email', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Company'>
<input
value={clientForm.company}
onChange={(event) =>
updateClientForm('company', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Role title'>
<input
value={clientForm.role_title}
onChange={(event) =>
updateClientForm('role_title', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Status'>
<select
value={clientForm.status}
onChange={(event) =>
updateClientForm('status', event.target.value)
}
className={inputClass()}
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-semibold text-[#19192d]'>
{client.name}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{client.role_title} · {client.company}
</p>
</div>
<span className='rounded-full bg-[#fbf8f1] px-3 py-1 text-xs font-semibold text-[#b17a1e]'>
{client.status}
</span>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
{(client.tags || '')
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3)
.map((tag) => (
<span
key={tag}
className='rounded-full bg-white px-3 py-1 text-xs text-[#72798a]'
>
{tag}
</span>
))}
</div>
</button>
))}
<option value='lead'>Lead</option>
<option value='active'>Active</option>
<option value='paused'>Paused</option>
<option value='completed'>Completed</option>
</select>
</Field>
<Field label='Next session'>
<input
value={clientForm.next_session_at}
type='datetime-local'
onChange={(event) =>
updateClientForm('next_session_at', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Tags'>
<input
value={clientForm.tags}
onChange={(event) =>
updateClientForm('tags', event.target.value)
}
className={inputClass()}
placeholder='executive, founder, delegation'
/>
</Field>
</div>
</Panel>
)}
{selectedClient && (
<div className='space-y-6'>
<Panel className='p-4'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
{selectedClient.package?.title || 'Coaching client'}
</p>
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'>
{selectedClient.name}
</h2>
<p className='mt-2 text-[#72798a]'>
{selectedClient.email}
</p>
</div>
<div className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-4 md:min-w-56'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
Next session
</div>
<p className='mt-2 text-sm text-[#72798a]'>
{selectedClient.next_session_at ||
'No session scheduled'}
</p>
</div>
</div>
<Panel className='overflow-hidden'>
<div className='border-b border-[#19192d]/10 p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Coaching relationships
</p>
<p className='mt-2 text-sm text-[#72798a]'>
{clients.length} clients in this workspace
</p>
</div>
<div className='mt-4 grid gap-4 lg:grid-cols-2'>
<div className='rounded-lg bg-[#fffdf9] p-5'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiTarget} size={18} />
Goals
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{selectedClient.goals}
</p>
</div>
<div className='rounded-lg bg-[#fffdf9] p-5'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentOutline} size={18} />
Private coach notes
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{selectedClient.notes}
</p>
</div>
</div>
</Panel>
<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 uppercase tracking-[0.16em] text-[#72798a]'>
<tr>
<th className='px-6 py-4 font-semibold'>Client</th>
<th className='px-6 py-4 font-semibold'>Status</th>
<th className='px-6 py-4 font-semibold'>Next session</th>
<th className='px-6 py-4 font-semibold'>Commitments</th>
<th className='px-6 py-4 font-semibold'>Tags</th>
<th className='px-6 py-4 font-semibold'></th>
</tr>
</thead>
<tbody className='divide-y divide-[#19192d]/10 bg-white'>
{clients.map((client) => {
const openActions = (client.action_items || []).filter(
(item) => item.status !== 'done',
).length;
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<div className='flex items-center justify-between gap-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Next-session prep
</p>
<h3 className='mt-2 text-lg font-semibold text-[#19192d]'>
Coach prep brief
</h3>
</div>
<span className='rounded-full bg-[#f3fbf8] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
Ready
</span>
</div>
</div>
{latestPrepBrief ? (
<div className='grid gap-4 p-5 lg:grid-cols-2'>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Client reflection
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.client_reflection} />
</div>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Open commitments
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.open_commitments} />
</div>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Suggested questions
</p>
<div className='mt-3'>
<PrepText
value={latestPrepBrief.suggested_questions}
/>
</div>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Watch points
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.sensitive_topics} />
</div>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4 lg:col-span-2'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Previous summary
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.previous_summary} />
</div>
</div>
</div>
) : (
<div className='p-5'>
<EmptyState label='No next-session prep brief yet.' />
</div>
)}
</Panel>
<div className='grid gap-4 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Session timeline
</h3>
</div>
<div className='space-y-4 p-5'>
{(selectedClient.sessions || []).length > 0 ? (
(selectedClient.sessions || []).map((session) => (
<div
key={session.id}
className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-4'
>
<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-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Pre-session reflection
</h3>
</div>
<div className='p-5'>
{latestReflection ? (
<div className='rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-4'>
<p className='text-sm leading-6 text-[#72798a]'>
{latestReflection.client_reflection}
return (
<tr key={client.id} className='hover:bg-[#fffdf9]'>
<td className='px-6 py-5'>
<p className='font-semibold text-[#19192d]'>
{client.name}
</p>
</div>
) : (
<EmptyState label='No client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
<div className='grid gap-4 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Open commitments
</h3>
</div>
<div className='space-y-4 p-5'>
{(selectedClient.action_items || []).length > 0 ? (
(selectedClient.action_items || []).map((item) => (
<div
key={item.id}
className='flex gap-3 rounded-lg border border-[#19192d]/10 bg-white p-4'
<p className='mt-1 text-sm text-[#72798a]'>
{[client.role_title, client.company]
.filter(Boolean)
.join(' · ') || client.email}
</p>
</td>
<td className='px-6 py-5'>
<span className='inline-flex rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
{client.status}
</span>
</td>
<td className='px-6 py-5 text-[#72798a]'>
{displayDateTime(client.next_session_at)}
</td>
<td className='px-6 py-5 text-[#72798a]'>
{openActions}
</td>
<td className='max-w-xs px-6 py-5 text-[#72798a]'>
{(client.tags || '').trim() || '—'}
</td>
<td className='px-6 py-5 text-right'>
<Link
href={`/clients/view?clientId=${client.id}`}
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<BaseIcon
path={mdiCheckCircleOutline}
size={18}
/>
</span>
<div>
<p className='font-semibold text-[#19192d]'>
{item.title}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{item.status.replace('_', ' ')}
</p>
</div>
</div>
))
) : (
<EmptyState label='No open commitments for this client.' />
)}
</div>
</Panel>
</div>
</div>
)}
</div>
Open
<BaseIcon path={mdiArrowRight} size={18} />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Panel>
</div>
</SectionMain>
</>

View File

@ -0,0 +1,812 @@
import {
mdiArrowLeft,
mdiCalendarClock,
mdiCheckCircleOutline,
mdiContentSaveOutline,
mdiFileDocumentOutline,
mdiLinkVariant,
mdiMicrophoneOutline,
mdiPlus,
mdiTarget,
mdiTrashCanOutline,
} 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';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
type ActionItem = {
id: string;
sessionId?: string;
title: string;
status: string;
due_at?: string;
notes?: string;
};
type PrepBrief = {
id: string;
sessionId?: string;
previous_summary?: string;
open_commitments?: string;
suggested_questions?: string;
sensitive_topics?: string;
client_reflection?: string;
};
type Resource = {
id: string;
title: string;
description?: string;
url?: string;
};
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;
};
type Client = {
id: string;
name: string;
email: string;
status: string;
goals?: string;
notes?: string;
company?: string;
role_title?: string;
tags?: string;
next_session_at?: string;
last_session_at?: string;
sessions?: Session[];
action_items?: ActionItem[];
resources?: Resource[];
prep_briefs?: PrepBrief[];
package?: {
title?: string;
};
};
type ClientForm = {
name: string;
email: string;
status: string;
company: string;
role_title: string;
tags: string;
goals: string;
notes: string;
next_session_at: string;
};
function formFromClient(client: Client): ClientForm {
return {
name: client.name || '',
email: client.email || '',
status: client.status || 'active',
company: client.company || '',
role_title: client.role_title || '',
tags: client.tags || '',
goals: client.goals || '',
notes: client.notes || '',
next_session_at: toDateTimeInput(client.next_session_at),
};
}
function toDateTimeInput(value?: string) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toISOString().slice(0, 16);
}
function displayDateTime(value?: string) {
if (!value) {
return 'No session scheduled';
}
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 Panel({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
function EmptyState({ label }: { label: string }) {
return (
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-6 text-sm text-[#72798a]'>
{label}
</p>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{label}
</span>
<div className='mt-2'>{children}</div>
</label>
);
}
function inputClass() {
return 'w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15';
}
function PrepText({ value }: { value?: string }) {
if (!value) {
return <EmptyState label='No prep details saved yet.' />;
}
return (
<div className='space-y-2'>
{value
.split(/\n|;/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => (
<p key={item} className='text-sm leading-6 text-[#72798a]'>
{item}
</p>
))}
</div>
);
}
const ClientDetail = () => {
const router = useRouter();
const [client, setClient] = React.useState<Client | null>(null);
const [clientForm, setClientForm] = React.useState<ClientForm | null>(null);
const [isSavingClient, setIsSavingClient] = React.useState(false);
const [newActionTitle, setNewActionTitle] = React.useState('');
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') {
return;
}
loadClient(router.query.clientId);
}, [router.isReady, router.query.clientId]);
const latestReflection = client?.prep_briefs?.find((brief) => {
return Boolean(brief.client_reflection);
});
const latestPrepBrief = client?.prep_briefs?.[0];
async function loadClient(clientId: string) {
const response = await axios.get(`/coaching/clients/${clientId}`);
setClient(response.data);
setClientForm(formFromClient(response.data));
}
function updateClientForm(field: keyof ClientForm, value: string) {
setClientForm((current) => {
if (!current) {
return current;
}
return {
...current,
[field]: value,
};
});
}
async function saveClient() {
if (!client || !clientForm) {
return;
}
setIsSavingClient(true);
setNotice('');
try {
const response = await axios.patch(`/coaching/clients/${client.id}`, {
...clientForm,
next_session_at: clientForm.next_session_at || null,
});
setClient(response.data);
setClientForm(formFromClient(response.data));
setNotice('Client record saved.');
} finally {
setIsSavingClient(false);
}
}
async function addActionItem() {
if (!client || !newActionTitle.trim()) {
return;
}
await axios.post(`/coaching/clients/${client.id}/action-items`, {
title: newActionTitle,
});
setNewActionTitle('');
await loadClient(client.id);
setNotice('Commitment added.');
}
async function addResource() {
if (!client || !newResourceTitle.trim()) {
return;
}
await axios.post(`/coaching/clients/${client.id}/resources`, {
title: newResourceTitle,
url: newResourceUrl,
resource_type: 'link',
is_shared: true,
});
setNewResourceTitle('');
setNewResourceUrl('');
await loadClient(client.id);
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>
<title>{getPageTitle(client?.name || 'Client')}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-6'>
<Link
href='/clients'
className='inline-flex items-center gap-2 text-sm font-semibold text-[#72798a] hover:text-[#19192d]'
>
<BaseIcon path={mdiArrowLeft} size={18} />
Clients
</Link>
</div>
<div className='mb-6 flex flex-col justify-between gap-6 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={mdiFileDocumentOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Client file
</span>
</div>
<h1 className='mt-3 text-xl font-semibold'>
{client?.name || 'Loading client...'}
</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
Edit profile context, review prep, and manage commitments and
shared resources.
</p>
</div>
<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>
{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>
)}
{client && clientForm ? (
<div className='space-y-6'>
<Panel className='p-7'>
<div className='flex flex-col justify-between gap-6 md:flex-row md:items-start'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
{client.package?.title || 'Coaching client'}
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Profile
</h2>
</div>
<button
type='button'
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSavingClient}
onClick={saveClient}
>
<BaseIcon path={mdiContentSaveOutline} size={18} />
{isSavingClient ? 'Saving...' : 'Save client'}
</button>
</div>
<div className='mt-6 grid gap-6 lg:grid-cols-4'>
<Field label='Name'>
<input
value={clientForm.name}
onChange={(event) =>
updateClientForm('name', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Email'>
<input
value={clientForm.email}
type='email'
onChange={(event) =>
updateClientForm('email', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Status'>
<select
value={clientForm.status}
onChange={(event) =>
updateClientForm('status', event.target.value)
}
className={inputClass()}
>
<option value='lead'>Lead</option>
<option value='active'>Active</option>
<option value='paused'>Paused</option>
<option value='completed'>Completed</option>
</select>
</Field>
<Field label='Next session'>
<input
value={clientForm.next_session_at}
type='datetime-local'
onChange={(event) =>
updateClientForm('next_session_at', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Company'>
<input
value={clientForm.company}
onChange={(event) =>
updateClientForm('company', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Role title'>
<input
value={clientForm.role_title}
onChange={(event) =>
updateClientForm('role_title', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Tags'>
<input
value={clientForm.tags}
onChange={(event) =>
updateClientForm('tags', event.target.value)
}
className={inputClass()}
/>
</Field>
</div>
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
<Field label='Goals'>
<textarea
value={clientForm.goals}
rows={5}
onChange={(event) =>
updateClientForm('goals', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Private coach notes'>
<textarea
value={clientForm.notes}
rows={5}
onChange={(event) =>
updateClientForm('notes', event.target.value)
}
className={inputClass()}
/>
</Field>
</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]'>
Next-session prep
</p>
<h3 className='mt-2 text-lg font-semibold text-[#19192d]'>
Coach prep brief
</h3>
</div>
{latestPrepBrief ? (
<div className='grid gap-6 p-7 lg:grid-cols-2'>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Client reflection
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.client_reflection} />
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Open commitments
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.open_commitments} />
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Suggested questions
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.suggested_questions} />
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Watch points
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.sensitive_topics} />
</div>
</div>
</div>
) : (
<div className='p-7'>
<EmptyState label='No next-session prep brief yet. Generate session memory or ask the client for a reflection.' />
</div>
)}
</Panel>
<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]'>
Open commitments
</h3>
</div>
<div className='border-b border-[#19192d]/10 p-7'>
<div className='flex flex-col gap-3 md:flex-row'>
<input
value={newActionTitle}
onChange={(event) =>
setNewActionTitle(event.target.value)
}
className={inputClass()}
placeholder='Add a commitment before the next session'
/>
<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={!newActionTitle.trim()}
onClick={addActionItem}
>
<BaseIcon path={mdiPlus} size={18} />
Add
</button>
</div>
</div>
<div className='space-y-4 p-7'>
{(client.action_items || []).length > 0 ? (
(client.action_items || []).map((item) => (
<div
key={item.id}
className='flex gap-3 rounded-none border border-[#19192d]/10 bg-white p-6'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
</span>
<div>
<p className='font-semibold text-[#19192d]'>
{item.title}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{item.status.replace('_', ' ')}
</p>
</div>
</div>
))
) : (
<EmptyState label='No open commitments for this client.' />
)}
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Shared resources
</h3>
</div>
<div className='space-y-3 border-b border-[#19192d]/10 p-7'>
<input
value={newResourceTitle}
onChange={(event) =>
setNewResourceTitle(event.target.value)
}
className={inputClass()}
placeholder='Resource title'
/>
<input
value={newResourceUrl}
onChange={(event) =>
setNewResourceUrl(event.target.value)
}
className={inputClass()}
placeholder='https://...'
/>
<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={!newResourceTitle.trim()}
onClick={addResource}
>
<BaseIcon path={mdiPlus} size={18} />
Add resource
</button>
</div>
<div className='space-y-4 p-7'>
{(client.resources || []).length > 0 ? (
(client.resources || []).map((resource) => (
<a
key={resource.id}
href={resource.url || '#'}
target='_blank'
rel='noreferrer'
className='flex items-start gap-3 rounded-none border border-[#19192d]/10 bg-white p-6 transition hover:bg-[#fffdf9]'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={mdiLinkVariant} size={18} />
</span>
<span>
<span className='block font-semibold text-[#19192d]'>
{resource.title}
</span>
<span className='mt-1 block text-sm text-[#72798a]'>
{resource.description || resource.url}
</span>
</span>
</a>
))
) : (
<EmptyState label='No shared resources for this client yet.' />
)}
</div>
</Panel>
</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]'>
Pre-session reflection
</h3>
</div>
<div className='p-7'>
{latestReflection ? (
<div className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'>
<p className='text-sm leading-6 text-[#72798a]'>
{latestReflection.client_reflection}
</p>
</div>
) : (
<EmptyState label='No client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
</div>
) : (
<Panel className='p-7'>
<p className='text-sm text-[#72798a]'>Loading client...</p>
</Panel>
)}
</div>
</SectionMain>
</>
);
};
ClientDetail.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ClientDetail;

View File

@ -4,11 +4,13 @@ import {
mdiCheckCircleOutline,
mdiClockOutline,
mdiFileDocumentEditOutline,
mdiMicrophoneOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import type { ReactElement } from 'react';
import BaseIcon from '../components/BaseIcon';
@ -81,7 +83,7 @@ function ShellCard({
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
@ -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 = [
@ -137,9 +160,9 @@ const Dashboard = () => {
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='grid gap-4 xl:grid-cols-[1.1fr_0.9fr]'>
<div className='rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
<div className='rounded-none bg-[#19192d] p-7 text-white'>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiViewDashboardOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Workspace overview
@ -155,14 +178,15 @@ const Dashboard = () => {
</p>
<div className='mt-5 flex flex-wrap gap-3'>
<Link
href='/session-memory'
className='rounded-full bg-[#b17a1e] 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'
className='rounded-full border border-white/25 px-4 py-2 text-sm font-semibold text-white'
className='rounded-none border border-white/25 px-4 py-2 text-sm font-semibold text-white'
>
Open client records
</Link>
@ -170,7 +194,7 @@ const Dashboard = () => {
</div>
<ShellCard className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Next session prep
</p>
{nextPrepBrief ? (
@ -187,8 +211,8 @@ const Dashboard = () => {
nextPrepBrief.previous_summary}
</p>
<Link
href={`/clients?clientId=${nextPrepBrief.client?.id}`}
className='mt-4 inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white'
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
<BaseIcon path={mdiArrowRight} size={18} />
@ -204,11 +228,11 @@ const Dashboard = () => {
</ShellCard>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<div className='mt-4 grid gap-6 md:grid-cols-2 xl:grid-cols-4'>
{stats.map((stat) => (
<Link key={stat.label} href={stat.href}>
<ShellCard className='h-full p-5 transition'>
<div className='flex items-start justify-between gap-4'>
<ShellCard className='h-full p-7 transition'>
<div className='flex items-start justify-between gap-6'>
<div>
<p className='text-sm font-semibold text-[#72798a]'>
{stat.label}
@ -217,7 +241,7 @@ const Dashboard = () => {
{loading ? '...' : stat.value}
</p>
</div>
<span className='grid h-9 w-9 place-items-center rounded-full bg-[#f3fbf8] text-[#35b7a5]'>
<span className='grid h-9 w-9 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={stat.icon} size={19} />
</span>
</div>
@ -226,12 +250,12 @@ const Dashboard = () => {
))}
</div>
<div className='mt-4 grid gap-4 xl:grid-cols-[0.95fr_1.05fr]'>
<div className='mt-4 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
<ShellCard>
<div className='border-b border-[#19192d]/10 p-4'>
<div className='flex items-center justify-between gap-4'>
<div className='border-b border-[#19192d]/10 p-6'>
<div className='flex items-center justify-between gap-6'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Client records
</p>
<h2 className='mt-2 text-lg font-semibold'>
@ -250,10 +274,10 @@ const Dashboard = () => {
{summary.activeClients.map((client) => (
<Link
key={client.id}
href={`/clients?clientId=${client.id}`}
className='block p-5 transition hover:bg-[#fffdf9]'
href={`/clients/view?clientId=${client.id}`}
className='block p-7 transition hover:bg-[#fffdf9]'
>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start justify-between gap-6'>
<div>
<p className='font-semibold text-[#19192d]'>
{client.name}
@ -262,7 +286,7 @@ const Dashboard = () => {
{client.role_title} · {client.company}
</p>
</div>
<span className='rounded-full bg-[#fbf8f1] px-3 py-1 text-xs font-semibold text-[#b17a1e]'>
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
{client.package?.title || 'Coaching'}
</span>
</div>
@ -272,10 +296,10 @@ const Dashboard = () => {
</ShellCard>
<ShellCard>
<div className='border-b border-[#19192d]/10 p-4'>
<div className='flex items-center justify-between gap-4'>
<div className='border-b border-[#19192d]/10 p-6'>
<div className='flex items-center justify-between gap-6'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Recent intelligence
</p>
<h2 className='mt-2 text-lg font-semibold'>
@ -309,10 +333,10 @@ const Dashboard = () => {
</div>
<ShellCard className='mt-4'>
<div className='border-b border-[#19192d]/10 p-4'>
<div className='flex items-center justify-between gap-4'>
<div className='border-b border-[#19192d]/10 p-6'>
<div className='flex items-center justify-between gap-6'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Next-session prep
</p>
<h2 className='mt-2 text-lg font-semibold'>
@ -331,10 +355,10 @@ const Dashboard = () => {
{summary.upcomingPrepBriefs.map((brief) => (
<Link
key={brief.id}
href={`/clients?clientId=${brief.client?.id}`}
className='block p-5 transition hover:bg-[#fffdf9]'
href={`/clients/view?clientId=${brief.client?.id}`}
className='block p-7 transition hover:bg-[#fffdf9]'
>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start justify-between gap-6'>
<div>
<p className='font-semibold text-[#19192d]'>
{brief.client?.name}
@ -343,7 +367,7 @@ const Dashboard = () => {
{brief.client?.role_title} · {brief.client?.company}
</p>
</div>
<span className='rounded-full bg-[#f3fbf8] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
Ready
</span>
</div>

View File

@ -7,25 +7,22 @@ import { getPageTitle } from '../config';
const ui = {
page: 'bg-[#fffdf9] text-[#19192d]',
banner:
'bg-gradient-to-r from-[#35b7a5] via-[#95b76e] to-[#c38a25] text-white',
navShell:
'rounded-full bg-white shadow-[0_18px_60px_rgba(31,31,50,0.08)] ring-1 ring-[#19192d]/5',
banner: 'bg-[#19192d] text-white',
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
ink: 'text-[#19192d]',
muted: 'text-[#72798a]',
accent: 'text-[#35b7a5]',
gold: 'text-[#b17a1e]',
gold: 'text-[#35b7a5]',
border: 'border-[#19192d]/10',
surface: 'bg-white',
softSurface: 'bg-[#fbf8f1]',
softSurface: 'bg-[#fffdf9]',
darkPanel: 'bg-[#19192d] text-white',
darkMuted: 'text-white/70',
button:
'bg-gradient-to-r from-[#36b39f] to-[#b98624] text-white shadow-[0_18px_45px_rgba(54,179,159,0.24)] transition hover:brightness-105',
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
card: 'rounded-[1.75rem] border border-[#19192d]/10 bg-white shadow-[0_18px_50px_rgba(31,31,50,0.05)]',
softCard: 'rounded-[1.75rem] border border-[#19192d]/10 bg-[#fbf8f1]',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#b17a1e]',
card: 'rounded-none border border-[#19192d]/10 bg-white ',
softCard: 'rounded-none border border-[#19192d]/10 bg-[#fffdf9]',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
};
@ -134,7 +131,7 @@ function Nav() {
href='/'
className='flex items-center gap-3 text-lg font-semibold tracking-tight'
>
<span className='flex h-9 w-9 items-center justify-center rounded-xl bg-[#f3fbf8] text-xl font-black text-[#35b7a5]'>
<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 SaaS Workspace</span>
@ -143,16 +140,17 @@ 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>
<Link
href='/intake/'
className={`rounded-full px-5 py-2.5 text-sm font-semibold ${ui.button}`}
className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
</div>
</header>
@ -194,7 +192,7 @@ function Mockup({ type }: { type: string }) {
<div className={`${ui.card} p-5`}>
<div className='flex items-center justify-between border-b border-[#19192d]/10 pb-4'>
<p className={`${ui.overline} text-xs`}>Recording</p>
<span className='rounded-full bg-[#e8f6f2] px-3 py-1 text-sm font-semibold text-[#248b7e]'>
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-sm font-semibold text-[#35b7a5]'>
Zoom connected
</span>
</div>
@ -213,9 +211,9 @@ function Mockup({ type }: { type: string }) {
].map((item) => (
<div
key={item}
className='flex items-center gap-3 rounded-2xl bg-[#fbf8f1] p-3'
className='flex items-center gap-3 rounded-none bg-[#fffdf9] p-3'
>
<span className='flex h-7 w-7 items-center justify-center rounded-full bg-[#35b7a5] text-sm text-white'>
<span className='flex h-7 w-7 items-center justify-center rounded-none bg-[#35b7a5] text-sm text-white'>
</span>
<span className='font-medium'>{item}</span>
@ -233,8 +231,8 @@ function Mockup({ type }: { type: string }) {
<h3 className='mt-2 text-2xl font-semibold'>
Sarah Mitchell · Session 4
</h3>
<div className='mt-5 rounded-2xl bg-[#fbf8f1] p-4'>
<p className='text-sm font-semibold text-[#b17a1e]'>Summary</p>
<div className='mt-5 rounded-none bg-[#fffdf9] p-4'>
<p className='text-sm font-semibold text-[#35b7a5]'>Summary</p>
<p className='mt-2 leading-7'>
Explored over-preparing for presentations and the shift from proving
competence to trusting it.
@ -244,7 +242,7 @@ function Mockup({ type }: { type: string }) {
{['Confidence', 'Delegation', 'Breakthrough'].map((item) => (
<span
key={item}
className='rounded-full bg-[#e8f6f2] px-3 py-1 text-sm font-semibold text-[#248b7e]'
className='rounded-none bg-[#fffdf9] px-3 py-1 text-sm font-semibold text-[#35b7a5]'
>
{item}
</span>
@ -252,11 +250,11 @@ function Mockup({ type }: { type: string }) {
</div>
<div className='mt-5 flex gap-3'>
<button
className={`rounded-full px-5 py-3 font-semibold ${ui.button}`}
className={`rounded-none px-5 py-3 font-semibold ${ui.button}`}
>
Looks good
</button>
<button className='rounded-full border border-[#19192d]/10 px-5 py-3 font-semibold'>
<button className='rounded-none border border-[#19192d]/10 px-5 py-3 font-semibold'>
Edit
</button>
</div>
@ -272,22 +270,22 @@ function Mockup({ type }: { type: string }) {
<span className={ui.muted}>Day 3</span>
</div>
<div className='space-y-4'>
<div className='rounded-2xl bg-[#fbf8f1] p-4'>
<div className='rounded-none bg-[#fffdf9] p-4'>
<p className='text-sm font-semibold text-[#72798a]'>You sent</p>
<p className='mt-2 leading-7'>
Last session you mentioned confidence feels different in small
groups vs large meetings. What did you notice this week?
</p>
</div>
<div className='ml-8 rounded-2xl bg-white p-4 ring-1 ring-[#19192d]/10'>
<div className='ml-8 rounded-none bg-white p-4 ring-1 ring-[#19192d]/10'>
<p className='text-sm font-semibold'>Sarah Mitchell</p>
<p className='mt-2 leading-7'>
I led the Monday standup and it felt easier. I think naming it
helped.
</p>
</div>
<div className='rounded-2xl bg-[#e8f6f2] p-4'>
<p className='text-sm font-semibold text-[#248b7e]'>
<div className='rounded-none bg-[#fffdf9] p-4'>
<p className='text-sm font-semibold text-[#35b7a5]'>
Coach-approved suggestion
</p>
<p className='mt-2 leading-7'>
@ -305,20 +303,20 @@ function Mockup({ type }: { type: string }) {
<p className={`${ui.overline} text-xs`}>Prep brief</p>
<h3 className='mt-2 text-2xl font-semibold'>Session 5</h3>
<div className='mt-5 grid gap-3'>
<div className='rounded-2xl bg-[#fbf8f1] p-4'>
<div className='rounded-none bg-[#fffdf9] p-4'>
<p className='font-semibold'>Last session themes</p>
<p className={`mt-2 ${ui.muted}`}>
Confidence · Delegation · Board presence
</p>
</div>
<div className='rounded-2xl bg-[#fbf8f1] p-4'>
<div className='rounded-none bg-[#fffdf9] p-4'>
<p className='font-semibold'>Open commitments</p>
<p className={`mt-2 ${ui.muted}`}>
Lead Monday standup without notes. Delegate Q2 report.
</p>
</div>
<div className={`${ui.darkPanel} rounded-2xl p-4`}>
<p className='text-sm font-semibold text-[#c79a38]'>
<div className={`${ui.darkPanel} rounded-none p-4`}>
<p className='text-sm font-semibold text-[#35b7a5]'>
Suggested opening
</p>
<p className='mt-2 leading-7'>
@ -332,8 +330,8 @@ function Mockup({ type }: { type: string }) {
return (
<div className={`${ui.card} p-5`}>
<div className={`${ui.darkPanel} rounded-[1.5rem] p-5`}>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<div className={`${ui.darkPanel} rounded-none p-5`}>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Your coaching space
</p>
<h3 className='mt-2 text-2xl font-semibold'>Sarah&apos;s Coaching</h3>
@ -343,7 +341,7 @@ function Mockup({ type }: { type: string }) {
{['Messages', 'Notes', 'Commitments', 'Resources'].map((item) => (
<div
key={item}
className='flex items-center justify-between rounded-2xl bg-[#fbf8f1] p-4'
className='flex items-center justify-between rounded-none bg-[#fffdf9] p-4'
>
<span className='font-medium'>{item}</span>
<span className={ui.accent}></span>
@ -371,7 +369,7 @@ function StepSection({
{step.title}
</h2>
<p className={`mt-5 text-lg leading-8 ${ui.muted}`}>{step.copy}</p>
<p className='mt-6 rounded-2xl bg-[#e8f6f2] px-5 py-4 font-semibold text-[#248b7e]'>
<p className='mt-6 rounded-none bg-[#fffdf9] px-5 py-4 font-semibold text-[#35b7a5]'>
{step.proof}
</p>
</div>
@ -441,13 +439,13 @@ export default function HowItWorks() {
<div className='mt-9 flex flex-wrap justify-center gap-4'>
<Link
href='/intake/'
className={`rounded-full px-8 py-4 font-semibold ${ui.button}`}
className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
<a
href='#coaching-week'
className='rounded-full border border-[#19192d]/10 bg-white px-8 py-4 font-semibold'
className='rounded-none border border-[#19192d]/10 bg-white px-8 py-4 font-semibold'
>
See your coaching week
</a>
@ -485,7 +483,7 @@ export default function HowItWorks() {
<div className='mx-auto max-w-7xl lg:px-8'>
<div className='grid grid-cols-1 gap-10 lg:grid-cols-[0.9fr_1.1fr]'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Go deeper
</p>
<h2 className='mt-4 font-serif text-5xl font-semibold leading-tight md:text-6xl'>
@ -501,7 +499,7 @@ export default function HowItWorks() {
{deeperItems.map(([title, copy]) => (
<div
key={title}
className='rounded-[1.5rem] border border-white/10 bg-white/5 p-6'
className='rounded-none border border-white/10 bg-white/5 p-6'
>
<h3 className='text-xl font-semibold'>{title}</h3>
<p className={`mt-3 leading-7 ${ui.darkMuted}`}>{copy}</p>
@ -518,7 +516,7 @@ export default function HowItWorks() {
<div className='grid grid-cols-1 items-center gap-10 lg:grid-cols-[0.95fr_1.05fr]'>
<div className={`${ui.card} overflow-hidden`}>
<div className={`p-6 ${ui.darkPanel}`}>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Client view
</p>
<h3 className='mt-2 text-2xl font-semibold'>
@ -529,20 +527,20 @@ export default function HowItWorks() {
</p>
</div>
<div className='grid gap-4 p-6'>
<div className='rounded-2xl bg-[#fbf8f1] p-4'>
<div className='rounded-none bg-[#fffdf9] p-4'>
<p className='font-semibold'>Reflection prompt</p>
<p className={`mt-2 leading-7 ${ui.muted}`}>
What is one boundary you could set this week to protect
strategic thinking time?
</p>
</div>
<div className='rounded-2xl border border-[#19192d]/10 p-4'>
<div className='rounded-none border border-[#19192d]/10 p-4'>
<p className='font-semibold'>Session notes 6 shared</p>
<p className={`mt-2 ${ui.muted}`}>
Board presence, CPO conversations, delegation trust.
</p>
</div>
<div className='rounded-2xl border border-[#19192d]/10 p-4'>
<div className='rounded-none border border-[#19192d]/10 p-4'>
<p className='font-semibold'>Commitments</p>
<p className={`mt-2 ${ui.muted}`}>2 open · 5 done</p>
</div>
@ -604,10 +602,10 @@ export default function HowItWorks() {
<section id='pricing' className='px-5 py-20'>
<div
className={`mx-auto grid max-w-7xl grid-cols-1 overflow-hidden rounded-[2rem] border lg:grid-cols-[0.9fr_1.1fr] ${ui.border} ${ui.darkPanel}`}
className={`mx-auto grid max-w-7xl grid-cols-1 overflow-hidden rounded-none border lg:grid-cols-[0.9fr_1.1fr] ${ui.border} ${ui.darkPanel}`}
>
<div className='p-8 md:p-12'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Simple pricing
</p>
<h2 className='mt-4 font-serif text-5xl font-semibold leading-tight'>
@ -625,7 +623,7 @@ export default function HowItWorks() {
</p>
<Link
href='/intake/'
className={`mt-8 inline-flex rounded-full px-7 py-4 font-semibold ${ui.button}`}
className={`mt-8 inline-flex rounded-none px-7 py-4 font-semibold ${ui.button}`}
>
Start free
</Link>
@ -655,7 +653,7 @@ export default function HowItWorks() {
</section>
<section className={`px-5 py-20 text-center ${ui.darkPanel}`}>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Ready to try it?
</p>
<h2 className='mx-auto mt-4 max-w-3xl font-serif text-5xl font-semibold leading-tight md:text-6xl'>
@ -664,7 +662,7 @@ export default function HowItWorks() {
<div className='mt-8 flex justify-center'>
<Link
href='/intake/'
className={`rounded-full px-8 py-4 font-semibold ${ui.button}`}
className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}
>
Start free
</Link>
@ -679,6 +677,8 @@ export default function HowItWorks() {
<div className='flex gap-5'>
<Link href='/privacy-policy/'>Privacy Policy</Link>
<Link href='/terms-of-use/'>Terms of Use</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/login/'>Login</Link>
</div>
</div>

View File

@ -7,25 +7,22 @@ import { getPageTitle } from '../config';
const ui = {
page: 'bg-[#fffdf9] text-[#19192d]',
banner:
'bg-gradient-to-r from-[#35b7a5] via-[#95b76e] to-[#c38a25] text-white',
navShell:
'rounded-full bg-white shadow-[0_18px_60px_rgba(31,31,50,0.08)] ring-1 ring-[#19192d]/5',
banner: 'bg-[#19192d] text-white',
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
ink: 'text-[#19192d]',
muted: 'text-[#72798a]',
accent: 'text-[#35b7a5]',
gold: 'text-[#b17a1e]',
gold: 'text-[#35b7a5]',
border: 'border-[#19192d]/10',
surface: 'bg-white',
softSurface: 'bg-[#fbf8f1]',
softSurface: 'bg-[#fffdf9]',
darkPanel: 'bg-[#19192d] text-white',
darkMuted: 'text-white/70',
button:
'bg-gradient-to-r from-[#36b39f] to-[#b98624] text-white shadow-[0_18px_45px_rgba(54,179,159,0.24)] transition hover:brightness-105',
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
card: 'rounded-[1.75rem] border border-[#19192d]/10 bg-white shadow-[0_18px_50px_rgba(31,31,50,0.05)]',
softCard: 'rounded-[1.75rem] border border-[#19192d]/10 bg-[#fbf8f1]',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#b17a1e]',
card: 'rounded-none border border-[#19192d]/10 bg-white ',
softCard: 'rounded-none border border-[#19192d]/10 bg-[#fffdf9]',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
};
@ -162,7 +159,7 @@ export default function Starter() {
href='/'
className='flex items-center gap-3 text-lg font-semibold tracking-tight'
>
<span className='flex h-9 w-9 items-center justify-center rounded-xl bg-[#f3fbf8] text-xl font-black text-[#35b7a5]'>
<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 SaaS Workspace</span>
@ -171,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-full px-5 py-2.5 text-sm font-semibold ${ui.button}`}
className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
Start assessment
</Link>
</div>
</header>
@ -208,9 +205,9 @@ export default function Starter() {
<div className='mt-9 flex justify-center'>
<Link
href='/intake/'
className={`rounded-full px-10 py-5 text-lg font-semibold ${ui.button}`}
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}`}>
@ -229,21 +226,20 @@ export default function Starter() {
</div>
<div className='relative mx-auto mt-12 max-w-4xl'>
<div className='absolute -inset-12 rounded-full bg-[#35b7a5]/10 blur-3xl' />
<div
className={`relative rounded-[2rem] border p-4 shadow-[0_24px_80px_rgba(31,31,50,0.10)] ${ui.border} ${ui.surface}`}
className={`relative rounded-none border p-4 ${ui.border} ${ui.surface}`}
>
<div className={`rounded-[1.5rem] p-4 ${ui.darkPanel}`}>
<div className={`rounded-none p-4 ${ui.darkPanel}`}>
<div className='mb-4 flex items-center justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.16em] text-[#c79a38]'>
<p className='text-xs uppercase tracking-[0.16em] text-[#35b7a5]'>
Session insights
</p>
<h2 className='mt-1 text-2xl font-semibold'>
Maya Chen · Session 4
</h2>
</div>
<span className='rounded-full bg-[#c79a38] px-3 py-1 text-sm font-semibold text-white'>
<span className='rounded-none bg-[#35b7a5] px-3 py-1 text-sm font-semibold text-white'>
Review
</span>
</div>
@ -254,13 +250,13 @@ export default function Starter() {
'Decision rights',
'Founder visibility',
].map((topic) => (
<div key={topic} className='rounded-2xl bg-white/10 p-4'>
<div key={topic} className='rounded-none bg-white/10 p-4'>
<p className='text-sm font-medium text-white'>{topic}</p>
</div>
))}
</div>
<div className='mt-4 rounded-2xl bg-white p-5 text-[#19192d]'>
<div className='mt-4 rounded-none bg-white p-5 text-[#19192d]'>
<p
className={`text-sm font-semibold uppercase tracking-[0.14em] ${ui.gold}`}
>
@ -277,8 +273,8 @@ export default function Starter() {
</div>
<div className='mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[0.9fr_1.1fr]'>
<div className='rounded-2xl bg-white/10 p-5'>
<p className='text-sm font-semibold text-[#c79a38]'>
<div className='rounded-none bg-white/10 p-5'>
<p className='text-sm font-semibold text-[#35b7a5]'>
Pattern
</p>
<p className={`mt-2 leading-7 ${ui.darkMuted}`}>
@ -286,8 +282,8 @@ export default function Starter() {
explicit decision boundaries.
</p>
</div>
<div className='rounded-2xl bg-white/10 p-5'>
<p className='text-sm font-semibold text-[#c79a38]'>
<div className='rounded-none bg-white/10 p-5'>
<p className='text-sm font-semibold text-[#35b7a5]'>
Follow-up draft
</p>
<p className={`mt-2 leading-7 ${ui.darkMuted}`}>
@ -311,7 +307,7 @@ export default function Starter() {
the narrative instead of defending the roadmap.
</p>
<p
className={`mt-4 rounded-2xl p-3 text-sm font-medium ${ui.softSurface}`}
className={`mt-4 rounded-none p-3 text-sm font-medium ${ui.softSurface}`}
>
Suggested reply: What changed in the room when you led that
way?
@ -350,7 +346,7 @@ export default function Starter() {
key={title}
className={`grid grid-cols-[3rem_1fr] gap-5 p-5 ${ui.card}`}
>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-[#35b7a5] font-semibold text-white'>
<div className='flex h-12 w-12 items-center justify-center rounded-none bg-[#35b7a5] font-semibold text-white'>
{index + 1}
</div>
<div>
@ -371,7 +367,7 @@ export default function Starter() {
>
<div className='mx-auto grid max-w-7xl grid-cols-1 gap-10 lg:grid-cols-[0.95fr_1.05fr] lg:px-8'>
<div className='flex flex-col justify-center'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Session memory
</p>
<h2 className='mt-4 font-serif text-5xl font-semibold leading-tight md:text-6xl'>
@ -391,7 +387,7 @@ export default function Starter() {
].map((item) => (
<div
key={item}
className='rounded-2xl border border-white/10 bg-white/5 p-4'
className='rounded-none border border-white/10 bg-white/5 p-4'
>
<p className='font-semibold'>{item}</p>
</div>
@ -400,7 +396,7 @@ export default function Starter() {
</div>
<div
className={`rounded-[2rem] border p-5 ${ui.border} ${ui.surface} ${ui.ink}`}
className={`rounded-none border p-5 ${ui.border} ${ui.surface} ${ui.ink}`}
>
<div
className={`flex flex-wrap items-center justify-between gap-4 border-b pb-5 ${ui.border}`}
@ -415,7 +411,7 @@ export default function Starter() {
Maya Chen · Session 5
</h3>
</div>
<span className='rounded-full bg-[#e8f6f2] px-4 py-2 text-sm font-semibold text-[#248b7e]'>
<span className='rounded-none bg-[#fffdf9] px-4 py-2 text-sm font-semibold text-[#35b7a5]'>
Ready when you are
</span>
</div>
@ -428,7 +424,7 @@ export default function Starter() {
].map(([value, label]) => (
<div
key={label}
className={`rounded-2xl p-4 ${ui.softSurface}`}
className={`rounded-none p-4 ${ui.softSurface}`}
>
<p className='text-3xl font-semibold'>{value}</p>
<p className={`mt-1 text-sm ${ui.muted}`}>{label}</p>
@ -436,7 +432,7 @@ export default function Starter() {
))}
</div>
<div className={`mt-5 rounded-2xl border p-5 ${ui.border}`}>
<div className={`mt-5 rounded-none border p-5 ${ui.border}`}>
<p
className={`text-sm font-semibold uppercase tracking-[0.14em] ${ui.gold}`}
>
@ -452,8 +448,8 @@ export default function Starter() {
</p>
</div>
<div className={`mt-5 rounded-2xl p-5 ${ui.darkPanel}`}>
<p className='text-sm font-semibold text-[#c79a38]'>
<div className={`mt-5 rounded-none p-5 ${ui.darkPanel}`}>
<p className='text-sm font-semibold text-[#35b7a5]'>
Suggested opening
</p>
<p className='mt-3 text-xl leading-8'>
@ -478,8 +474,8 @@ export default function Starter() {
</h2>
<p className={`mt-6 text-lg leading-8 ${ui.muted}`}>
Private notes stay private. Shared notes are approved.
Client-facing content carries the coach&apos;s voice, not a generic
chatbot voice.
Client-facing content carries the coach&apos;s voice, not a
generic chatbot voice.
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
@ -504,7 +500,7 @@ export default function Starter() {
</div>
<Link
href='/intake/'
className={`rounded-full px-6 py-3 text-center font-semibold ${ui.button}`}
className={`rounded-none px-6 py-3 text-center font-semibold ${ui.button}`}
>
Start your workspace
</Link>
@ -527,10 +523,10 @@ export default function Starter() {
<section id='pricing' className='px-5 py-20'>
<div
className={`mx-auto grid max-w-7xl grid-cols-1 overflow-hidden rounded-[2rem] border lg:grid-cols-[0.9fr_1.1fr] ${ui.border} ${ui.darkPanel}`}
className={`mx-auto grid max-w-7xl grid-cols-1 overflow-hidden rounded-none border lg:grid-cols-[0.9fr_1.1fr] ${ui.border} ${ui.darkPanel}`}
>
<div className='p-8 md:p-12'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#c79a38]'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Template package
</p>
<h2 className='mt-4 font-serif text-5xl font-semibold leading-tight'>
@ -552,7 +548,7 @@ export default function Starter() {
].map((item) => (
<div
key={item}
className={`rounded-2xl p-4 font-medium ${ui.softSurface}`}
className={`rounded-none p-4 font-medium ${ui.softSurface}`}
>
{item}
</div>
@ -560,7 +556,7 @@ export default function Starter() {
</div>
<Link
href='/intake/'
className={`mt-8 inline-flex rounded-full px-7 py-4 font-semibold ${ui.button}`}
className={`mt-8 inline-flex rounded-none px-7 py-4 font-semibold ${ui.button}`}
>
Create workspace
</Link>
@ -576,6 +572,8 @@ export default function Starter() {
<div className='flex gap-5'>
<Link href='/privacy-policy/'>Privacy Policy</Link>
<Link href='/terms-of-use/'>Terms of Use</Link>
<Link href='/about/'>About</Link>
<Link href='/services/'>Services</Link>
<Link href='/login/'>Login</Link>
</div>
</div>

View File

@ -36,7 +36,7 @@ function Panel({
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
@ -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,16 +59,33 @@ 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);
const response = await axios.patch(`/coaching/intake-leads/${leadId}/status`, {
status: 'archived',
});
setNotice('');
const response = await axios.patch(
`/coaching/intake-leads/${leadId}/status`,
{
status: 'archived',
},
);
setLeads((current) =>
current.map((lead) => {
if (lead.id === leadId) {
@ -87,8 +105,8 @@ export default function IntakeLeads() {
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<div className='mb-4 rounded-none bg-[#19192d] p-7 text-white'>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiFormTextbox} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Intake
@ -101,16 +119,22 @@ export default function IntakeLeads() {
</p>
</div>
<div className='grid gap-4'>
{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'>
<div className='flex flex-col justify-between gap-4 lg:flex-row lg:items-start'>
<div className='flex flex-col justify-between gap-6 lg:flex-row lg:items-start'>
<div>
<div className='flex flex-wrap items-center gap-3'>
<h2 className='text-lg font-semibold text-[#19192d]'>
{lead.name}
</h2>
<span className='rounded-full bg-[#fbf8f1] px-3 py-1 text-xs font-semibold text-[#b17a1e]'>
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
{lead.status}
</span>
</div>
@ -133,7 +157,7 @@ export default function IntakeLeads() {
type='button'
disabled={updatingLeadId === lead.id}
onClick={() => convertLead(lead.id)}
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
>
<BaseIcon path={mdiAccountConvertOutline} size={18} />
Convert to client
@ -144,7 +168,7 @@ export default function IntakeLeads() {
type='button'
disabled={updatingLeadId === lead.id}
onClick={() => archiveLead(lead.id)}
className='inline-flex items-center gap-2 rounded-full border border-[#19192d]/10 bg-white px-3 py-1.5 text-sm font-semibold text-[#19192d] disabled:opacity-50'
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'
>
<BaseIcon path={mdiArchiveOutline} size={18} />
Archive
@ -153,25 +177,25 @@ export default function IntakeLeads() {
</div>
</div>
<div className='mt-5 grid gap-4 lg:grid-cols-3'>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
<div className='mt-5 grid gap-6 lg:grid-cols-3'>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Goal
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{lead.goal || 'No goal provided.'}
</p>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Challenge
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
{lead.challenge || 'No challenge provided.'}
</p>
</div>
<div className='rounded-lg bg-[#fffdf9] p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Desired outcome
</p>
<p className='mt-3 text-sm leading-6 text-[#72798a]'>

View File

@ -6,7 +6,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const fieldClass =
'mt-2 w-full rounded-lg border border-[#19192d]/10 bg-white px-3 py-2 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15';
'mt-2 w-full rounded-none border border-[#19192d]/10 bg-white px-4 py-3 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15';
type IntakeValues = {
name: string;
@ -83,7 +83,7 @@ export default function Intake() {
</Link>
<Link
href='/login/'
className='rounded-full border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold'
className='rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold'
>
Login
</Link>
@ -92,22 +92,21 @@ export default function Intake() {
<section className='mx-auto grid max-w-6xl gap-8 px-5 py-10 lg:grid-cols-[0.85fr_1.15fr]'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Coaching intake
</p>
<h1 className='mt-4 font-serif text-5xl font-semibold leading-tight md:text-6xl'>
Start with the coaching context that matters.
</h1>
<p className='mt-5 max-w-xl text-lg leading-8 text-[#72798a]'>
Share what you want to work on. Your coach can review this,
create a client record, and prepare the first session around your
goals.
Share what you want to work on. Your coach can review this, create
a client record, and prepare the first session around your goals.
</p>
</div>
<div className='rounded-lg border border-[#19192d]/10 bg-white p-5'>
<div className='rounded-none border border-[#19192d]/10 bg-white p-7'>
{isSubmitted ? (
<div className='rounded-lg bg-[#f3fbf8] p-5'>
<div className='rounded-none bg-[#fffdf9] p-7'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Received
</p>
@ -120,7 +119,7 @@ export default function Intake() {
</div>
) : (
<form className='space-y-4' onSubmit={submitIntake}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-6 md:grid-cols-2'>
<FieldLabel label='Name'>
<input
required
@ -144,7 +143,7 @@ export default function Intake() {
</FieldLabel>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-6 md:grid-cols-2'>
<FieldLabel label='Company'>
<input
value={values.company}
@ -170,7 +169,9 @@ export default function Intake() {
required
rows={4}
value={values.goal}
onChange={(event) => updateValue('goal', event.target.value)}
onChange={(event) =>
updateValue('goal', event.target.value)
}
className={fieldClass}
/>
</FieldLabel>
@ -197,7 +198,7 @@ export default function Intake() {
/>
</FieldLabel>
<label className='flex gap-3 rounded-lg bg-[#fbf8f1] p-4 text-sm leading-6 text-[#72798a]'>
<label className='flex gap-3 rounded-none bg-[#fffdf9] p-6 text-sm leading-6 text-[#72798a]'>
<input
type='checkbox'
checked={values.consent_ai_notes}
@ -213,7 +214,7 @@ export default function Intake() {
<button
type='submit'
disabled={isSubmitting}
className='rounded-full bg-[#35b7a5] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50'
className='rounded-none bg-[#35b7a5] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50'
>
{isSubmitting ? 'Submitting...' : 'Send intake'}
</button>

View File

@ -14,22 +14,19 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks';
const ui = {
page: 'bg-[#fffdf9] text-[#19192d]',
banner:
'bg-gradient-to-r from-[#35b7a5] via-[#95b76e] to-[#c38a25] text-white',
navShell:
'rounded-full bg-white shadow-[0_18px_60px_rgba(31,31,50,0.08)] ring-1 ring-[#19192d]/5',
banner: 'bg-[#19192d] text-white',
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
ink: 'text-[#19192d]',
muted: 'text-[#72798a]',
accent: 'text-[#35b7a5]',
gold: 'text-[#b17a1e]',
gold: 'text-[#35b7a5]',
border: 'border-[#19192d]/10',
surface: 'bg-white',
softSurface: 'bg-[#fbf8f1]',
softSurface: 'bg-[#fffdf9]',
darkPanel: 'bg-[#19192d] text-white',
button:
'bg-gradient-to-r from-[#36b39f] to-[#b98624] text-white shadow-[0_18px_45px_rgba(54,179,159,0.24)] transition hover:brightness-105',
card: 'rounded-[1.75rem] border border-[#19192d]/10 bg-white shadow-[0_18px_50px_rgba(31,31,50,0.05)]',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#b17a1e]',
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
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]',
};
@ -70,7 +67,7 @@ function Nav() {
href='/'
className='flex items-center gap-3 text-lg font-semibold tracking-tight'
>
<span className='flex h-9 w-9 items-center justify-center rounded-xl bg-[#f3fbf8] text-xl font-black text-[#35b7a5]'>
<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 SaaS Workspace</span>
@ -85,7 +82,7 @@ function Nav() {
</nav>
<Link
href='/intake/'
className={`rounded-full px-5 py-2.5 text-sm font-semibold ${ui.button}`}
className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}
>
Start free
</Link>
@ -218,7 +215,7 @@ export default function Login() {
<button
key={account.email}
type='button'
className='rounded-2xl border border-[#19192d]/10 bg-[#fbf8f1] px-3 py-3 text-left transition hover:border-[#35b7a5] hover:bg-[#f3fbf8]'
className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-3 py-3 text-left transition hover:border-[#35b7a5] hover:bg-[#fffdf9]'
onClick={() =>
setValues({
email: account.email,
@ -245,7 +242,7 @@ export default function Login() {
<Field
name='email'
type='email'
className={`w-full rounded-2xl border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
className={`w-full rounded-none border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
/>
</label>
@ -257,7 +254,7 @@ export default function Login() {
<Field
name='password'
type={showPassword ? 'text' : 'password'}
className={`w-full rounded-2xl border px-4 py-3 pr-12 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
className={`w-full rounded-none border px-4 py-3 pr-12 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
/>
<button
type='button'
@ -286,7 +283,7 @@ export default function Login() {
</label>
<Link
href='/forgot'
className='font-semibold text-[#248b7e] hover:underline'
className='font-semibold text-[#35b7a5] hover:underline'
>
Forgot password?
</Link>
@ -295,7 +292,7 @@ export default function Login() {
<button
type='submit'
disabled={isFetching}
className={`w-full rounded-full px-6 py-4 font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${ui.button}`}
className={`w-full rounded-none px-6 py-4 font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${ui.button}`}
>
{isFetching ? 'Opening...' : 'Login'}
</button>
@ -307,7 +304,7 @@ export default function Login() {
Don&apos;t have an account yet?{' '}
<Link
href='/register'
className='font-semibold text-[#248b7e] hover:underline'
className='font-semibold text-[#35b7a5] hover:underline'
>
Create one
</Link>

View File

@ -45,7 +45,7 @@ function Panel({
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
@ -70,7 +70,7 @@ function FieldWrap({
}
const inputClassName =
'w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15 disabled:bg-[#fbf8f1] disabled:text-[#72798a]';
'w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15 disabled:bg-[#fffdf9] disabled:text-[#72798a]';
const EditUsers = () => {
const { currentUser } = useAppSelector((state) => state.auth);
@ -106,8 +106,8 @@ const EditUsers = () => {
</Head>
<main className='mx-auto max-w-7xl px-6 py-6'>
<div className='mb-4 rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<div className='mb-4 rounded-none bg-[#19192d] p-7 text-white'>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<span className='text-lg'></span>
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Account
@ -129,7 +129,7 @@ const EditUsers = () => {
<div className='grid gap-5 xl:grid-cols-[180px_1fr]'>
<div>
<p className='text-sm font-semibold text-[#19192d]'>Avatar</p>
<div className='mt-3 flex h-32 w-32 items-center justify-center overflow-hidden rounded-full border border-[#19192d]/10 bg-[#f3fbf8]'>
<div className='mt-3 flex h-32 w-32 items-center justify-center overflow-hidden rounded-none border border-[#19192d]/10 bg-[#fffdf9]'>
{avatarUrl ? (
<Image
className='h-full w-full object-cover object-center'
@ -164,7 +164,7 @@ const EditUsers = () => {
</div>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<div className='grid gap-6 md:grid-cols-2 xl:grid-cols-3'>
<FieldWrap label='First name'>
<Field
className={inputClassName}
@ -232,20 +232,20 @@ const EditUsers = () => {
<div className='flex flex-wrap justify-end gap-3 border-t border-[#19192d]/10 pt-5'>
<button
type='button'
className='rounded-full border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
className='rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
onClick={() => router.push('/users/users-list')}
>
Cancel
</button>
<button
type='reset'
className='rounded-full border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
className='rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
>
Reset
</button>
<button
type='submit'
className='rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
className='rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
>
Save profile
</button>

View File

@ -11,18 +11,15 @@ import { getPageTitle } from '../config';
const ui = {
page: 'bg-[#fffdf9] text-[#19192d]',
banner:
'bg-gradient-to-r from-[#35b7a5] via-[#95b76e] to-[#c38a25] text-white',
navShell:
'rounded-full bg-white shadow-[0_18px_60px_rgba(31,31,50,0.08)] ring-1 ring-[#19192d]/5',
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',
button:
'bg-gradient-to-r from-[#36b39f] to-[#b98624] text-white shadow-[0_18px_45px_rgba(54,179,159,0.24)] transition hover:brightness-105',
card: 'rounded-[1.75rem] border border-[#19192d]/10 bg-white shadow-[0_18px_50px_rgba(31,31,50,0.05)]',
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#b17a1e]',
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
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]',
};
@ -42,7 +39,7 @@ function Nav() {
href='/'
className='flex items-center gap-3 text-lg font-semibold tracking-tight'
>
<span className='flex h-9 w-9 items-center justify-center rounded-xl bg-[#f3fbf8] text-xl font-black text-[#35b7a5]'>
<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 SaaS Workspace</span>
@ -57,7 +54,7 @@ function Nav() {
</nav>
<Link
href='/login/'
className='rounded-full border border-[#19192d]/10 bg-white px-5 py-2.5 text-sm font-semibold'
className='rounded-none border border-[#19192d]/10 bg-white px-5 py-2.5 text-sm font-semibold'
>
Login
</Link>
@ -188,7 +185,7 @@ export default function Register() {
<Field
type='email'
name='email'
className={`w-full rounded-2xl border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
className={`w-full rounded-none border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
/>
</label>
@ -199,7 +196,7 @@ export default function Register() {
<Field
type='password'
name='password'
className={`w-full rounded-2xl border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
className={`w-full rounded-none border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
/>
</label>
@ -210,14 +207,14 @@ export default function Register() {
<Field
type='password'
name='confirm'
className={`w-full rounded-2xl border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
className={`w-full rounded-none border px-4 py-3 outline-none transition focus:border-[#35b7a5] focus:ring-4 focus:ring-[#35b7a5]/10 ${ui.border}`}
/>
</label>
<button
type='submit'
disabled={loading}
className={`w-full rounded-full px-6 py-4 font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${ui.button}`}
className={`w-full rounded-none px-6 py-4 font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${ui.button}`}
>
{loading ? 'Creating...' : 'Create account'}
</button>
@ -228,7 +225,7 @@ export default function Register() {
Already have an account?{' '}
<Link
href='/login'
className='font-semibold text-[#248b7e] hover:underline'
className='font-semibold text-[#35b7a5] hover:underline'
>
Login
</Link>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -20,7 +20,7 @@ function Panel({
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
@ -40,8 +40,8 @@ function ActionButton({
}) {
const className =
variant === 'primary'
? 'rounded-full bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
: 'rounded-full border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]';
? 'rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white'
: 'rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]';
if (href) {
return (
@ -128,8 +128,8 @@ const UsersTablesPage = () => {
</Head>
<main className='mx-auto max-w-7xl px-6 py-6'>
<div className='mb-4 rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<div className='mb-4 rounded-none bg-[#19192d] p-7 text-white'>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<span className='text-lg'></span>
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Team access
@ -141,7 +141,7 @@ const UsersTablesPage = () => {
</p>
</div>
<Panel className='mb-4 p-4'>
<Panel className='mb-4 p-6'>
<div className='flex flex-wrap items-center gap-3'>
{hasCreatePermission && (
<ActionButton href='/users/users-new'>Add user</ActionButton>