Compare commits
10 Commits
2a10483480
...
266ae01a7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266ae01a7e | ||
|
|
449541f6b8 | ||
|
|
2c902aa822 | ||
|
|
cbebf46653 | ||
|
|
be3bbea7aa | ||
|
|
058b824a5c | ||
|
|
1a1c1a02ce | ||
|
|
196c6f48ed | ||
|
|
c61345a89d | ||
|
|
ba861959bd |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
*/build/
|
||||
backend/public/uploads/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("sessions", "audio_url", {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
});
|
||||
|
||||
await queryInterface.addColumn("sessions", "audio_filename", {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
});
|
||||
|
||||
await queryInterface.addColumn("sessions", "audio_mime_type", {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
});
|
||||
|
||||
await queryInterface.addColumn("sessions", "audio_size", {
|
||||
type: Sequelize.DataTypes.INTEGER,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("sessions", "audio_size");
|
||||
await queryInterface.removeColumn("sessions", "audio_mime_type");
|
||||
await queryInterface.removeColumn("sessions", "audio_filename");
|
||||
await queryInterface.removeColumn("sessions", "audio_url");
|
||||
},
|
||||
};
|
||||
@ -19,6 +19,10 @@ module.exports = function(sequelize, DataTypes) {
|
||||
next_session_prep: { type: DataTypes.TEXT },
|
||||
private_coach_notes: { type: DataTypes.TEXT },
|
||||
shared_client_notes: { type: DataTypes.TEXT },
|
||||
audio_url: { type: DataTypes.TEXT },
|
||||
audio_filename: { type: DataTypes.TEXT },
|
||||
audio_mime_type: { type: DataTypes.TEXT },
|
||||
audio_size: { type: DataTypes.INTEGER },
|
||||
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
185
frontend/src/pages/about.tsx
Normal file
185
frontend/src/pages/about.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const ui = {
|
||||
page: 'bg-[#fffdf9] text-[#19192d]',
|
||||
banner: 'bg-[#19192d] text-white',
|
||||
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
|
||||
ink: 'text-[#19192d]',
|
||||
muted: 'text-[#72798a]',
|
||||
accent: 'text-[#35b7a5]',
|
||||
border: 'border-[#19192d]/10',
|
||||
surface: 'bg-white',
|
||||
softSurface: 'bg-[#fffdf9]',
|
||||
darkPanel: 'bg-[#19192d] text-white',
|
||||
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
|
||||
section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
|
||||
card: 'rounded-none border border-[#19192d]/10 bg-white',
|
||||
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
|
||||
heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
|
||||
};
|
||||
|
||||
const credentials = [
|
||||
'Founder and executive coaching',
|
||||
'Leadership transitions',
|
||||
'Decision systems and operating rhythm',
|
||||
'Confidential client workspace',
|
||||
];
|
||||
|
||||
const principles = [
|
||||
[
|
||||
'Still human',
|
||||
'AI helps with memory, prep, and follow-up. The coaching judgment stays with the coach.',
|
||||
],
|
||||
[
|
||||
'Between-session continuity',
|
||||
'The work does not disappear when the call ends. Notes, commitments, and resources stay connected.',
|
||||
],
|
||||
[
|
||||
'Private by default',
|
||||
'Private coach notes stay private. Clients see only approved notes, commitments, and resources.',
|
||||
],
|
||||
];
|
||||
|
||||
function Nav() {
|
||||
return (
|
||||
<header className='sticky top-3 z-50 px-5 pt-5'>
|
||||
<div
|
||||
className={`mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2.5 backdrop-blur-xl md:px-5 md:py-3 ${ui.navShell}`}
|
||||
>
|
||||
<Link href='/' className='flex items-center gap-3 text-lg font-semibold'>
|
||||
<span className='flex h-9 w-9 items-center justify-center rounded-none bg-[#fffdf9] text-xl font-black text-[#35b7a5]'>
|
||||
C
|
||||
</span>
|
||||
<span className='sr-only'>Coaching Workspace</span>
|
||||
</Link>
|
||||
<nav className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}>
|
||||
<Link href='/how-it-works/'>How it works</Link>
|
||||
<Link href='/about/'>About</Link>
|
||||
<Link href='/services/'>Services</Link>
|
||||
<Link href='/client-portal/'>Client login</Link>
|
||||
</nav>
|
||||
<Link href='/intake/' className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}>
|
||||
Start assessment
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutCoach() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('About Coach')}</title>
|
||||
</Head>
|
||||
|
||||
<main className={`min-h-screen ${ui.page}`}>
|
||||
<div className={`${ui.banner} px-5 py-4 text-center text-lg`}>
|
||||
<p className='text-xs font-bold uppercase tracking-[0.34em]'>
|
||||
Coaching practice
|
||||
</p>
|
||||
<p className='mt-2 text-xl font-medium md:text-2xl'>
|
||||
A public coach profile connected to a private client workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Nav />
|
||||
|
||||
<section className={`${ui.section} grid gap-12 lg:grid-cols-[0.9fr_1.1fr] lg:items-center`}>
|
||||
<div>
|
||||
<p className={ui.overline}>About the coach</p>
|
||||
<h1 className={`${ui.heading} mt-5 text-5xl leading-tight md:text-6xl`}>
|
||||
Coaching for founders and senior leaders who are carrying the room.
|
||||
</h1>
|
||||
<p className={`mt-7 text-xl leading-8 ${ui.muted}`}>
|
||||
Use this page to introduce the coach, their niche, credentials,
|
||||
and point of view. The template is built for practices where trust,
|
||||
confidentiality, and continuity matter as much as booking the first call.
|
||||
</p>
|
||||
<div className='mt-9 flex flex-wrap gap-3'>
|
||||
<Link href='/intake/' className={`rounded-none px-7 py-4 font-semibold ${ui.button}`}>
|
||||
Start assessment
|
||||
</Link>
|
||||
<Link
|
||||
href='/services/'
|
||||
className='rounded-none border border-[#19192d]/10 bg-white px-7 py-4 font-semibold text-[#19192d]'
|
||||
>
|
||||
View services
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${ui.card} p-6`}>
|
||||
<div className='border-b border-[#19192d]/10 pb-5'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||
Coach profile
|
||||
</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold'>Alex Morgan</h2>
|
||||
<p className={`mt-2 text-lg ${ui.muted}`}>
|
||||
Executive coach for founders, operators, and leadership teams.
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-6 grid gap-3'>
|
||||
{credentials.map((item) => (
|
||||
<div key={item} className='flex items-center gap-3 rounded-none bg-[#fffdf9] p-4'>
|
||||
<span className='flex h-7 w-7 items-center justify-center rounded-none bg-[#35b7a5] text-sm font-semibold text-white'>
|
||||
✓
|
||||
</span>
|
||||
<span className='font-medium'>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${ui.section} pt-0`}>
|
||||
<div className='grid gap-5 md:grid-cols-3'>
|
||||
{principles.map(([title, copy]) => (
|
||||
<div key={title} className={`${ui.card} p-6`}>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||
Principle
|
||||
</p>
|
||||
<h3 className='mt-3 text-2xl font-semibold'>{title}</h3>
|
||||
<p className={`mt-4 leading-7 ${ui.muted}`}>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`px-5 py-20 text-center ${ui.darkPanel}`}>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
|
||||
Ready to talk?
|
||||
</p>
|
||||
<h2 className='mx-auto mt-4 max-w-3xl font-serif text-5xl font-semibold leading-tight md:text-6xl'>
|
||||
Start with an assessment and bring context into the first call.
|
||||
</h2>
|
||||
<div className='mt-8 flex justify-center'>
|
||||
<Link href='/intake/' className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}>
|
||||
Start assessment
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={`border-t px-5 py-10 ${ui.border}`}>
|
||||
<div className={`mx-auto flex max-w-7xl flex-col justify-between gap-5 text-sm md:flex-row ${ui.muted}`}>
|
||||
<p>© 2026 Coaching SaaS Workspace. Coaching beyond the session.</p>
|
||||
<div className='flex gap-5'>
|
||||
<Link href='/privacy-policy/'>Privacy Policy</Link>
|
||||
<Link href='/terms-of-use/'>Terms of Use</Link>
|
||||
<Link href='/login/'>Login</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AboutCoach.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
812
frontend/src/pages/clients/view.tsx
Normal file
812
frontend/src/pages/clients/view.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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's voice, not a generic
|
||||
chatbot voice.
|
||||
Client-facing content carries the coach'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>
|
||||
|
||||
@ -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]'>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
194
frontend/src/pages/services.tsx
Normal file
194
frontend/src/pages/services.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const ui = {
|
||||
page: 'bg-[#fffdf9] text-[#19192d]',
|
||||
banner: 'bg-[#19192d] text-white',
|
||||
navShell: 'rounded-none bg-white ring-1 ring-[#19192d]/5',
|
||||
ink: 'text-[#19192d]',
|
||||
muted: 'text-[#72798a]',
|
||||
accent: 'text-[#35b7a5]',
|
||||
border: 'border-[#19192d]/10',
|
||||
surface: 'bg-white',
|
||||
darkPanel: 'bg-[#19192d] text-white',
|
||||
button: 'bg-[#35b7a5] text-white transition hover:brightness-105',
|
||||
section: 'mx-auto max-w-7xl px-5 py-20 lg:px-8',
|
||||
card: 'rounded-none border border-[#19192d]/10 bg-white',
|
||||
overline: 'text-sm font-bold uppercase tracking-[0.28em] text-[#35b7a5]',
|
||||
heading: 'font-serif font-semibold tracking-tight text-[#19192d]',
|
||||
};
|
||||
|
||||
const packages = [
|
||||
{
|
||||
name: 'Leadership Assessment',
|
||||
price: 'Intro',
|
||||
copy: 'A focused first step for founders and senior leaders who want to clarify the coaching agenda.',
|
||||
items: ['intake review', 'goals and pressure points', 'recommended coaching plan'],
|
||||
},
|
||||
{
|
||||
name: 'Founder Coaching',
|
||||
price: 'Monthly',
|
||||
copy: 'Ongoing 1:1 coaching with session memory, commitments, resources, and between-session accountability.',
|
||||
items: ['two sessions per month', 'shared client portal', 'coach-approved follow-up'],
|
||||
},
|
||||
{
|
||||
name: 'Executive Operating Rhythm',
|
||||
price: 'Custom',
|
||||
copy: 'A deeper engagement for leaders navigating delegation, decision rights, and team operating cadence.',
|
||||
items: ['leadership themes', 'decision-system work', 'next-session prep briefs'],
|
||||
},
|
||||
];
|
||||
|
||||
const outcomes = [
|
||||
'Clearer decisions',
|
||||
'Better delegation boundaries',
|
||||
'Stronger leadership presence',
|
||||
'Follow-through between sessions',
|
||||
'A private place for client resources',
|
||||
'Less admin after every session',
|
||||
];
|
||||
|
||||
function Nav() {
|
||||
return (
|
||||
<header className='sticky top-3 z-50 px-5 pt-5'>
|
||||
<div
|
||||
className={`mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2.5 backdrop-blur-xl md:px-5 md:py-3 ${ui.navShell}`}
|
||||
>
|
||||
<Link href='/' className='flex items-center gap-3 text-lg font-semibold'>
|
||||
<span className='flex h-9 w-9 items-center justify-center rounded-none bg-[#fffdf9] text-xl font-black text-[#35b7a5]'>
|
||||
C
|
||||
</span>
|
||||
<span className='sr-only'>Coaching Workspace</span>
|
||||
</Link>
|
||||
<nav className={`hidden items-center gap-6 text-base font-medium ${ui.ink} md:flex`}>
|
||||
<Link href='/how-it-works/'>How it works</Link>
|
||||
<Link href='/about/'>About</Link>
|
||||
<Link href='/services/'>Services</Link>
|
||||
<Link href='/client-portal/'>Client login</Link>
|
||||
</nav>
|
||||
<Link href='/intake/' className={`rounded-none px-5 py-2.5 text-sm font-semibold ${ui.button}`}>
|
||||
Start assessment
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Services() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Coaching Services')}</title>
|
||||
</Head>
|
||||
|
||||
<main className={`min-h-screen ${ui.page}`}>
|
||||
<div className={`${ui.banner} px-5 py-4 text-center text-lg`}>
|
||||
<p className='text-xs font-bold uppercase tracking-[0.34em]'>
|
||||
Services and packages
|
||||
</p>
|
||||
<p className='mt-2 text-xl font-medium md:text-2xl'>
|
||||
Sell coaching clearly, then manage the whole relationship in one workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Nav />
|
||||
|
||||
<section className={`${ui.section} text-center`}>
|
||||
<p className={ui.overline}>Coaching offers</p>
|
||||
<h1 className={`${ui.heading} mx-auto mt-5 max-w-4xl text-5xl leading-tight md:text-6xl`}>
|
||||
Packages for leaders who need clarity, accountability, and continuity.
|
||||
</h1>
|
||||
<p className={`mx-auto mt-7 max-w-3xl text-xl leading-8 ${ui.muted}`}>
|
||||
Use these cards as starter offers. A coach can rename packages,
|
||||
change pricing, and route every CTA into the intake flow.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className={`${ui.section} pt-0`}>
|
||||
<div className='grid gap-5 lg:grid-cols-3'>
|
||||
{packages.map((item) => (
|
||||
<div key={item.name} className={`${ui.card} flex flex-col p-6`}>
|
||||
<div className='border-b border-[#19192d]/10 pb-5'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||
{item.price}
|
||||
</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold'>{item.name}</h2>
|
||||
<p className={`mt-4 leading-7 ${ui.muted}`}>{item.copy}</p>
|
||||
</div>
|
||||
<div className='mt-5 grid gap-3'>
|
||||
{item.items.map((included) => (
|
||||
<div key={included} className='flex items-center gap-3'>
|
||||
<span className='flex h-6 w-6 items-center justify-center rounded-none bg-[#35b7a5] text-xs font-semibold text-white'>
|
||||
✓
|
||||
</span>
|
||||
<span className='font-medium'>{included}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link href='/intake/' className={`mt-7 inline-flex justify-center rounded-none px-6 py-4 font-semibold ${ui.button}`}>
|
||||
Start intake
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${ui.section} pt-0`}>
|
||||
<div className={`${ui.card} grid gap-8 p-7 lg:grid-cols-[0.9fr_1.1fr] lg:items-center`}>
|
||||
<div>
|
||||
<p className={ui.overline}>Expected outcomes</p>
|
||||
<h2 className={`${ui.heading} mt-4 text-4xl leading-tight md:text-5xl`}>
|
||||
Make the value of coaching concrete before the first call.
|
||||
</h2>
|
||||
<p className={`mt-5 text-lg leading-8 ${ui.muted}`}>
|
||||
FounderCoaching-style public sites make outcomes visible. This
|
||||
template connects those outcomes to the working system behind
|
||||
the scenes: clients, sessions, notes, resources, and follow-up.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
{outcomes.map((item) => (
|
||||
<div key={item} className='rounded-none bg-[#fffdf9] p-4 font-semibold'>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`px-5 py-20 text-center ${ui.darkPanel}`}>
|
||||
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
|
||||
Next step
|
||||
</p>
|
||||
<h2 className='mx-auto mt-4 max-w-3xl font-serif text-5xl font-semibold leading-tight md:text-6xl'>
|
||||
Start with intake, then convert the lead into a client workspace.
|
||||
</h2>
|
||||
<div className='mt-8 flex justify-center'>
|
||||
<Link href='/intake/' className={`rounded-none px-8 py-4 font-semibold ${ui.button}`}>
|
||||
Start assessment
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className={`border-t px-5 py-10 ${ui.border}`}>
|
||||
<div className={`mx-auto flex max-w-7xl flex-col justify-between gap-5 text-sm md:flex-row ${ui.muted}`}>
|
||||
<p>© 2026 Coaching SaaS Workspace. Coaching beyond the session.</p>
|
||||
<div className='flex gap-5'>
|
||||
<Link href='/privacy-policy/'>Privacy Policy</Link>
|
||||
<Link href='/terms-of-use/'>Terms of Use</Link>
|
||||
<Link href='/login/'>Login</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Services.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
414
frontend/src/pages/session-memory/view.tsx
Normal file
414
frontend/src/pages/session-memory/view.tsx
Normal file
@ -0,0 +1,414 @@
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCheckCircleOutline,
|
||||
mdiFileDocumentEditOutline,
|
||||
mdiSendOutline,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import { getPageTitle } from '../../config';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
|
||||
type Client = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Session = {
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
client?: Client;
|
||||
session_at?: string;
|
||||
transcript_notes?: string;
|
||||
ai_summary?: string;
|
||||
key_topics?: string;
|
||||
goals_discussed?: string;
|
||||
blockers?: string;
|
||||
commitments?: string;
|
||||
homework?: string;
|
||||
emotional_themes?: string;
|
||||
important_quotes?: string;
|
||||
follow_up_email?: string;
|
||||
next_session_prep?: string;
|
||||
private_coach_notes?: string;
|
||||
shared_client_notes?: string;
|
||||
audio_url?: string;
|
||||
audio_filename?: string;
|
||||
};
|
||||
|
||||
type TranscriptTurn = {
|
||||
speaker: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function Panel({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status?: string }) {
|
||||
const isShared = status === 'shared';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`rounded-none px-3 py-1 text-xs font-semibold ${
|
||||
isShared ? 'bg-[#fffdf9] text-[#35b7a5]' : 'bg-[#fffdf9] text-[#35b7a5]'
|
||||
}`}
|
||||
>
|
||||
{isShared ? 'Shared' : 'Coach draft'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function parseTranscriptTurns(value?: string) {
|
||||
const turns: TranscriptTurn[] = [];
|
||||
|
||||
String(value || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const match = line.match(/^([A-ZА-Я][\wА-Яа-я -]{0,30}):\s*(.+)$/u);
|
||||
|
||||
if (match) {
|
||||
turns.push({
|
||||
speaker: match[1],
|
||||
text: match[2],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTurn = turns[turns.length - 1];
|
||||
if (previousTurn) {
|
||||
previousTurn.text = `${previousTurn.text} ${line}`;
|
||||
return;
|
||||
}
|
||||
|
||||
turns.push({
|
||||
speaker: 'Transcript',
|
||||
text: line,
|
||||
});
|
||||
});
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
function TranscriptConversation({ value }: { value?: string }) {
|
||||
const turns = parseTranscriptTurns(value);
|
||||
|
||||
if (turns.length === 0) {
|
||||
return (
|
||||
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-4 text-sm text-[#72798a]'>
|
||||
No transcript saved for this session.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{turns.map((turn, index) => (
|
||||
<div
|
||||
key={`${turn.speaker}-${index}-${turn.text.slice(0, 18)}`}
|
||||
className='grid gap-3 border border-[#19192d]/10 bg-[#fffdf9] p-4 md:grid-cols-[72px_1fr]'
|
||||
>
|
||||
<div className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
|
||||
{turn.speaker}
|
||||
</div>
|
||||
<p className='text-sm leading-6 text-[#19192d]'>{turn.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayDateTime(value?: string) {
|
||||
if (!value) {
|
||||
return 'No date';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function DetailBlock({ label, value }: { label: string; value?: string }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='border-t border-[#19192d]/10 pt-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
|
||||
{label}
|
||||
</p>
|
||||
<p className='mt-2 whitespace-pre-wrap text-sm leading-6 text-[#72798a]'>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SessionMemoryView = () => {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = React.useState<Session | null>(null);
|
||||
const [notice, setNotice] = React.useState('');
|
||||
const [audioSourceUrl, setAudioSourceUrl] = React.useState('');
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [isSharing, setIsSharing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!router.isReady || typeof router.query.sessionId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
loadSession(router.query.sessionId);
|
||||
}, [router.isReady, router.query.sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!session?.audio_url) {
|
||||
setAudioSourceUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
let revoked = false;
|
||||
let objectUrl = '';
|
||||
|
||||
axios
|
||||
.get(`/coaching/sessions/${session.id}/audio`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
.then((response) => {
|
||||
objectUrl = URL.createObjectURL(response.data);
|
||||
|
||||
if (revoked) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioSourceUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
setNotice('Audio file could not be loaded.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
revoked = true;
|
||||
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [session?.id, session?.audio_url]);
|
||||
|
||||
async function loadSession(sessionId: string) {
|
||||
const response = await axios.get(`/coaching/sessions/${sessionId}`);
|
||||
setSession(response.data);
|
||||
}
|
||||
|
||||
async function shareSession() {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSharing(true);
|
||||
try {
|
||||
const response = await axios.patch(`/coaching/sessions/${session.id}/share`, {
|
||||
shared_client_notes: session.shared_client_notes,
|
||||
});
|
||||
setSession(response.data);
|
||||
setNotice('Session shared with the client.');
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Delete "${session.title || 'Client session'}"? This cannot be undone.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await axios.delete(`/coaching/sessions/${session.id}`);
|
||||
await router.push('/session-memory');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle(session?.title || 'Session')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<button
|
||||
type='button'
|
||||
className='mb-6 inline-flex items-center gap-2 text-sm font-semibold text-[#72798a] hover:text-[#19192d]'
|
||||
onClick={() => router.push('/session-memory')}
|
||||
>
|
||||
<BaseIcon path={mdiArrowLeft} size={18} />
|
||||
Session archive
|
||||
</button>
|
||||
|
||||
<div className='mb-4 flex flex-col justify-between gap-4 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
|
||||
<div>
|
||||
<div className='flex items-center gap-3 text-[#35b7a5]'>
|
||||
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
|
||||
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
|
||||
Session
|
||||
</span>
|
||||
</div>
|
||||
<h1 className='mt-3 max-w-3xl text-xl font-semibold'>
|
||||
{session?.title || 'Client session'}
|
||||
</h1>
|
||||
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
|
||||
{session
|
||||
? `${session.client?.name || 'No client'} · ${displayDateTime(
|
||||
session.session_at,
|
||||
)}`
|
||||
: 'Loading session...'}
|
||||
</p>
|
||||
</div>
|
||||
{session && <StatusPill status={session.status} />}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className='mb-4 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] px-4 py-3 text-sm font-semibold text-[#35b7a5]'>
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session ? (
|
||||
<div className='grid gap-6 lg:grid-cols-[1.2fr_0.8fr]'>
|
||||
<Panel className='p-5'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||
Transcript
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
<TranscriptConversation value={session.transcript_notes} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Panel className='p-5'>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{session.status !== 'shared' && (
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex items-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
|
||||
disabled={isSharing}
|
||||
onClick={shareSession}
|
||||
>
|
||||
<BaseIcon path={mdiSendOutline} size={18} />
|
||||
{isSharing ? 'Sharing...' : 'Share with client'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex items-center gap-2 rounded-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d] disabled:opacity-50'
|
||||
disabled={isDeleting}
|
||||
onClick={deleteSession}
|
||||
>
|
||||
<BaseIcon path={mdiTrashCanOutline} size={18} />
|
||||
{isDeleting ? 'Deleting...' : 'Delete session'}
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{session.audio_url && (
|
||||
<Panel className='p-5'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||
Audio recording
|
||||
</p>
|
||||
{audioSourceUrl ? (
|
||||
<audio
|
||||
className='mt-4 w-full'
|
||||
controls
|
||||
src={audioSourceUrl}
|
||||
/>
|
||||
) : (
|
||||
<p className='mt-4 text-sm text-[#72798a]'>
|
||||
Loading audio...
|
||||
</p>
|
||||
)}
|
||||
<p className='mt-2 text-xs text-[#72798a]'>
|
||||
{session.audio_filename || 'Session audio'}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel className='space-y-4 p-5'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||
Coach memory
|
||||
</p>
|
||||
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
|
||||
{session.ai_summary || 'No summary saved.'}
|
||||
</p>
|
||||
</div>
|
||||
<DetailBlock label='Key topics' value={session.key_topics} />
|
||||
<DetailBlock label='Goals discussed' value={session.goals_discussed} />
|
||||
<DetailBlock label='Blockers' value={session.blockers} />
|
||||
<DetailBlock label='Commitments' value={session.commitments} />
|
||||
<DetailBlock label='Homework' value={session.homework} />
|
||||
<DetailBlock label='Emotional themes' value={session.emotional_themes} />
|
||||
<DetailBlock label='Important quotes' value={session.important_quotes} />
|
||||
<DetailBlock label='Shared client notes' value={session.shared_client_notes} />
|
||||
<DetailBlock label='Follow-up email' value={session.follow_up_email} />
|
||||
<DetailBlock label='Next-session prep' value={session.next_session_prep} />
|
||||
<DetailBlock label='Private coach notes' value={session.private_coach_notes} />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Panel className='p-5'>
|
||||
<p className='text-sm text-[#72798a]'>Loading session...</p>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SessionMemoryView.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default SessionMemoryView;
|
||||
1
frontend/src/pages/start-session.tsx
Normal file
1
frontend/src/pages/start-session.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './session-memory';
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user