From d793c40d10b3c3850d3bf065267a327326033a70 Mon Sep 17 00:00:00 2001 From: wmj Date: Wed, 26 Aug 2026 12:27:08 +0800 Subject: [PATCH] =?UTF-8?q?rebuild:=20complete=20redesign=20=E2=80=94=20en?= =?UTF-8?q?terprise=20coaching=20ops=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full redesign for Agile/DT/Enterprise coach personas - Light McKinsey-style UI: white + navy accent, Inter font - Marcus: Team Portfolio (14 teams, filters, sparklines, maturity) - Marcus: Team Detail (maturity bar, session log, action tracker) - Marcus: Session Capture (tagging toolbar, AI coaching questions, AI summary) - Priya: Workshop List + Live Capture (Decision/Tension/Question tags) - Priya: Workshop Summary (AI-generated, grouped by capture type) - David: Portfolio Reports + CEO Report Generator (Gemini-powered) - AI Assistant: 5 modes (Questions/Prep/Workshop/Summary/CEO Report) - Playbooks: Agile/DT/Facilitation library with filters - Persona switcher: Marcus / Priya / David - Netlify function: /api/gemini (server-side API proxy) - netlify.toml: functions dir + API route redirect --- index.html | 9 +- netlify.toml | 6 + netlify/functions/gemini.js | 55 +++ src/App.jsx | 807 ++++++++-------------------------- src/data/mock.js | 397 +++++++++++++++++ src/views/AIAssistant.jsx | 182 ++++++++ src/views/Dashboard.jsx | 287 ++++++++++++ src/views/Playbooks.jsx | 115 +++++ src/views/Reports.jsx | 201 +++++++++ src/views/SessionCapture.jsx | 252 +++++++++++ src/views/TeamDetail.jsx | 235 ++++++++++ src/views/TeamPortfolio.jsx | 184 ++++++++ src/views/WorkshopCapture.jsx | 283 ++++++++++++ src/views/WorkshopList.jsx | 84 ++++ tailwind.config.js | 46 +- 15 files changed, 2492 insertions(+), 651 deletions(-) create mode 100644 netlify/functions/gemini.js create mode 100644 src/data/mock.js create mode 100644 src/views/AIAssistant.jsx create mode 100644 src/views/Dashboard.jsx create mode 100644 src/views/Playbooks.jsx create mode 100644 src/views/Reports.jsx create mode 100644 src/views/SessionCapture.jsx create mode 100644 src/views/TeamDetail.jsx create mode 100644 src/views/TeamPortfolio.jsx create mode 100644 src/views/WorkshopCapture.jsx create mode 100644 src/views/WorkshopList.jsx diff --git a/index.html b/index.html index cbb8008..0a08308 100644 --- a/index.html +++ b/index.html @@ -5,11 +5,12 @@ Falah Coach Portal - - - + + + + - +
diff --git a/netlify.toml b/netlify.toml index df9f053..d17c7ce 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,12 @@ [build] command = "npm run build" publish = "dist" + functions = "netlify/functions" + +[[redirects]] + from = "/api/*" + to = "/.netlify/functions/:splat" + status = 200 [[redirects]] from = "/*" diff --git a/netlify/functions/gemini.js b/netlify/functions/gemini.js new file mode 100644 index 0000000..7d907a1 --- /dev/null +++ b/netlify/functions/gemini.js @@ -0,0 +1,55 @@ +export default async (req, context) => { + if (req.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + } + + try { + const { prompt, context: ctx, systemPrompt } = await req.json(); + const apiKey = process.env.GEMINI_API_KEY || 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI'; + + const system = systemPrompt || 'You are an expert agile and enterprise coaching assistant for Falah Consulting. Be concise, practical, and outcome-focused.'; + const fullPrompt = ctx ? `Context: ${ctx}\n\n${prompt}` : prompt; + + const geminiRes = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: system }] }, + contents: [{ role: 'user', parts: [{ text: fullPrompt }] }], + generationConfig: { temperature: 0.7, maxOutputTokens: 1024 }, + }), + } + ); + + if (!geminiRes.ok) { + const err = await geminiRes.json(); + throw new Error(err?.error?.message || `Gemini HTTP ${geminiRes.status}`); + } + + const data = await geminiRes.json(); + const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; + + return new Response(JSON.stringify({ text }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (err) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; + +export const config = { path: '/api/gemini' }; diff --git a/src/App.jsx b/src/App.jsx index f769528..dab0277 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,650 +1,207 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { - LayoutDashboard, - MessageSquareQuote, - Headphones, - BookOpen, - PlayCircle, - PauseCircle, - SkipForward, - SkipBack, - TerminalSquare, - Bot, - User, - Zap, - TrendingUp, - FileText, - ChevronRight, - FolderOpen, - Menu, - X, - Loader2, - Volume2, + LayoutDashboard, Users, Zap, BookOpen, BarChart2, + ChevronDown, LogOut, Menu, X, BrainCircuit, } from 'lucide-react'; +import { PERSONAS, ENGAGEMENTS } from './data/mock.js'; +import Dashboard from './views/Dashboard.jsx'; +import TeamPortfolio from './views/TeamPortfolio.jsx'; +import TeamDetail from './views/TeamDetail.jsx'; +import SessionCapture from './views/SessionCapture.jsx'; +import AIAssistant from './views/AIAssistant.jsx'; +import Playbooks from './views/Playbooks.jsx'; +import WorkshopList from './views/WorkshopList.jsx'; +import WorkshopCapture from './views/WorkshopCapture.jsx'; +import Reports from './views/Reports.jsx'; -/* ========================================================================== - CONFIG - ========================================================================== */ - -const GEMINI_API_KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI'; -const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`; - -const AI_SYSTEM_PROMPT = `You are a hostile, senior executive playing a roleplay simulation for management consultants at Falah Consulting. -Your persona: a hard-nosed C-suite executive (CIO, CFO, or CEO) who is deeply sceptical of consultants and demands hard financial evidence. -Rules: -- Stay in character as the hostile executive. Never break character. -- Challenge every claim with "So what?", "Prove it financially", or "What's the EBITDA impact?" -- Be concise — max 3 sentences per reply. -- If the user uses buzzwords without data, push back aggressively. -- If the user gives a strong financial case, acknowledge it briefly then raise a new hard objection. -- Do not explain what you are doing or use meta-commentary.`; - -/* ========================================================================== - MOCK DATA - ========================================================================== */ - -const MOCK_AUDIO_TRACKS = [ - { - id: 1, title: 'The 100-Day Turnaround', duration: '14:20', tag: 'Strategy', author: 'Senior Partner A', - ttsScript: `Welcome to the 100-Day Turnaround briefing. In a private equity turnaround, the first 100 days are existential. You must identify the three core value levers: cost, revenue, and working capital. In the first two weeks, conduct a rapid diagnostic: interview 20 key stakeholders, map the P&L to operational drivers, and identify the top 5 EBITDA improvement opportunities. By day 30, present a fully costed transformation roadmap with clear accountability. Prioritise quick wins — at least 3 million in annualised savings within 60 days — to build credibility with the board. The cardinal rule: never surprise your sponsor. Weekly steering updates, always leading with financial metrics. Thank you for listening.`, - }, - { - id: 2, title: 'Negotiating with Hostile Boards', duration: '22:15', tag: 'Leadership', author: 'Principal B', - ttsScript: `This briefing covers negotiating with hostile boards. A hostile board is not an obstacle — it is a data source. Their aggression tells you exactly where the credibility gap is. Rule one: never walk into a board session without pre-reads reviewed by at least 2 board members in advance. Rule two: lead with the number they care most about — usually free cash flow or EBITDA margin. Rule three: when challenged, do not defend your analysis. Reframe. Ask: What evidence would change your view? This turns a confrontation into a collaborative hypothesis. Always close with a decision matrix, not a recommendation. Give them the power to choose — between your option and an option you've already stress-tested as inferior. That's the Falah method for hostile board navigation.`, - }, - { - id: 3, title: 'M&A Synergy Identification', duration: '18:45', tag: 'Finance', author: 'Director C', - ttsScript: `M&A synergy identification is where most deals destroy value. Acquirers over-estimate revenue synergies by 40% and under-estimate integration costs by 30%. Our Falah framework starts with a synergy taxonomy: cost synergies, revenue synergies, and financial synergies. Cost synergies — headcount, procurement, and real estate — are the only ones you should bank on in year one. Revenue synergies require market overlap, sales force alignment, and at least 18 months of integration. The critical discipline is the synergy validation session: every synergy claim must be signed off by the business unit owner, not the deal team. If they won't sign, cut it from the model. Underpromise and overdeliver. That is the path to a successful integration.`, - }, - { - id: 4, title: 'Digital Transformation Pitfalls', duration: '31:10', tag: 'Technology', author: 'Principal D', - ttsScript: `Digital transformation fails 70% of the time. The cause is almost never technology. It is governance, change management, and unclear ROI definition. Pitfall one: starting with technology before defining the business problem. Always begin with the customer journey or the operational bottleneck. Pitfall two: underinvesting in change management. Allocate at least 20% of your transformation budget to people and process. Pitfall three: measuring outputs instead of outcomes. Replace number of APIs deployed with reduction in order-to-cash cycle time. The Falah digital transformation framework has three phases: diagnose, design, and deploy. In diagnose, map every process that touches the customer. In design, identify the minimum viable change to deliver maximum impact. In deploy, run parallel operations until the new system proves reliability.`, - }, - { - id: 5, title: 'Pricing Strategies in Inflation', duration: '12:55', tag: 'Economics', author: 'Partner E', - ttsScript: `Pricing in an inflationary environment requires a fundamental shift in mindset. Reactive pricing — simply passing on cost increases — destroys customer relationships. Proactive pricing — anticipating inflation and embedding flexibility in contracts — preserves margin and trust. There are three strategies we deploy at Falah. First, value-based pricing: anchor your price to the economic value you deliver, not your cost base. Second, dynamic pricing tiers: create good, better, best options so customers self-select, protecting your premium tier. Third, contract architecture: build in annual CPI-linked adjustments so price increases feel automatic, not adversarial. The bottom line: every 1% improvement in price realisation drops directly to EBITDA. Pricing is the highest-leverage tool in your margin toolkit.`, - }, +/* ── Nav config ── */ +const NAV = [ + { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { id: 'portfolio', label: 'Teams', icon: Users }, + { id: 'workshops', label: 'Workshops', icon: Zap }, + { id: 'ai', label: 'AI Assistant', icon: BrainCircuit }, + { id: 'playbooks', label: 'Playbooks', icon: BookOpen }, + { id: 'reports', label: 'Portfolio', icon: BarChart2 }, ]; -const MOCK_WIKI_DIRECTORY = [ - { folder: 'Core Frameworks', items: ['Falah MECE Guidelines', 'Value Creation Matrix', 'Go-To-Market Playbook'] }, - { folder: 'Banking Edition', items: ['Retail Bank Cost Reduction', 'Fintech Threat Analysis', 'Regulatory Compliance 2026'] }, - { folder: 'Telco Edition', items: ['5G Monetization', 'Churn Reduction Models'] }, -]; +/* ── Persona-aware nav visibility ── */ +const PERSONA_NAV = { + marcus: ['dashboard', 'portfolio', 'ai', 'playbooks'], + priya: ['dashboard', 'workshops', 'ai', 'playbooks'], + david: ['dashboard', 'reports', 'ai', 'playbooks'], +}; -/* ========================================================================== - HELPERS - ========================================================================== */ +export default function App() { + const [personaId, setPersonaId] = useState('marcus'); + const [nav, setNav] = useState({ view: 'dashboard', params: {} }); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [personaOpen, setPersonaOpen] = useState(false); -function formatTime(secs) { - if (!isFinite(secs) || secs < 0) return '00:00'; - const m = Math.floor(secs / 60).toString().padStart(2, '0'); - const s = Math.floor(secs % 60).toString().padStart(2, '0'); - return `${m}:${s}`; -} + const persona = PERSONAS.find(p => p.id === personaId); + const engagement = persona.activeEngagement + ? ENGAGEMENTS.find(e => e.id === persona.activeEngagement) + : null; -/* ========================================================================== - AI CO-PILOT — Gemini 2.5 Flash - ========================================================================== */ + const navigate = useCallback((view, params = {}) => { + setNav({ view, params }); + setSidebarOpen(false); + }, []); -const INITIAL_MESSAGES = [ - { role: 'system', text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.' }, - { role: 'assistant', text: "I don't have time for buzzwords today. You want £2M of my budget to \"transform\" teams that are already delivering. Where is the hard financial justification? I need EBITDA impact, not velocity charts." }, -]; + const visibleNav = NAV.filter(n => PERSONA_NAV[personaId].includes(n.id)); -const AICoPilotView = () => { - const [messages, setMessages] = useState(INITIAL_MESSAGES); - const [input, setInput] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const chatEndRef = useRef(null); - - useEffect(() => { - chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, loading]); - - const buildPayload = (msgs) => ({ - systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] }, - contents: msgs - .filter((m) => m.role !== 'system') - .map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.text }] })), - generationConfig: { temperature: 0.85, maxOutputTokens: 256 }, - }); - - const handleSend = async (e) => { - e?.preventDefault(); - const trimmed = input.trim(); - if (!trimmed || loading) return; - const next = [...messages, { role: 'user', text: trimmed }]; - setMessages(next); - setInput(''); - setLoading(true); - setError(''); - try { - const res = await fetch(GEMINI_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildPayload(next)), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err?.error?.message || `HTTP ${res.status}`); - } - const data = await res.json(); - const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); - if (!reply) throw new Error('Empty response from Gemini'); - setMessages((prev) => [...prev, { role: 'assistant', text: reply }]); - } catch (err) { - setError(`AI error: ${err.message}`); - } finally { - setLoading(false); + /* ── View router ── */ + const renderView = () => { + const { view, params } = nav; + switch (view) { + case 'dashboard': return ; + case 'portfolio': return ; + case 'team': return ; + case 'session': return ; + case 'workshops': return ; + case 'workshop-live': return ; + case 'ai': return ; + case 'playbooks': return ; + case 'reports': return ; + default: return ; } }; - return ( -
-
-

AI Co-Pilot & Simulator

-

Engage in high-pressure executive roleplay to refine your delivery.

-
-
-
- {messages.map((msg, i) => ( -
- {msg.role !== 'user' && ( -
- {msg.role === 'system' ? : } -
- )} -
- {msg.text} -
- {msg.role === 'user' && ( -
- -
- )} -
- ))} - {loading && ( -
-
-
- - Thinking... -
-
- )} - {error &&
{error}
} -
-
-
-
- {['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map((cmd) => ( - - ))} -
-
- setInput(e.target.value)} - placeholder="Deploy a response or command..." disabled={loading} - className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 md:py-4 pr-12 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-slate-500 disabled:opacity-50 text-sm md:text-base" /> - -
-
-
-
- ); -}; - -/* ========================================================================== - AUDIO LIBRARY VIEW - ========================================================================== */ - -const AudioLibraryView = ({ setActiveTrack }) => ( -
-
-

Tactical Audio Briefings

-

Exclusive micro-learning tracks from Falah's leadership network.

-
-
- {MOCK_AUDIO_TRACKS.map((track) => ( -
setActiveTrack(track)} - className="group bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-emerald-500/5"> -
- {track.tag} -
- -
+ const SidebarContent = () => ( + <> + {/* Logo */} +
+
+
+ + +
-

{track.title}

-
- {track.author} - {track.duration} -
+
Falah
+
Coach Portal
- ))} -
-
-); - -/* ========================================================================== - MISSION CONTROL - ========================================================================== */ - -const MissionControlView = ({ setActiveView, setActiveTrack }) => ( -
-
-

Mission Control

-

Welcome back. Your Falah Coach portal is ready.

-
-
-
-
-
-
- Featured Briefing -
-

{MOCK_AUDIO_TRACKS[0].title}

-

- Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners. -

-
- -
-
- {[ - { label: 'AI Simulator', desc: 'Practice hostile boardroom scenarios.', view: 'simulator', icon: MessageSquareQuote }, - { label: 'Loop Wiki', desc: 'Review the Falah MECE Guidelines.', view: 'wiki', icon: BookOpen }, - ].map(({ label, desc, view, icon: Icon }) => ( -
setActiveView(view)} - className="bg-slate-900 border border-slate-800 hover:border-blue-500/40 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-blue-500/5 flex-1 flex flex-col justify-between"> -
- -
-
-

{label}

-

{desc}

-
-
- ))} -
-
-
-); - -/* ========================================================================== - WIKI VIEW - ========================================================================== */ - -const MSLoopWikiView = () => { - const [activePage, setActivePage] = useState('Falah MECE Guidelines'); - return ( -
-
-

MS Loop Integration

-

Falah Playbooks & Living Intellectual Property.

-
-
-
-
Directory
-
- {MOCK_WIKI_DIRECTORY.map((dir, idx) => ( -
-
- {dir.folder} -
-
- {dir.items.map((item) => ( - - ))} -
-
- ))} -
-
-
-
-
-
-
-
- loop.microsoft.com/falah/{activePage.toLowerCase().replace(/ /g, '-')} -
-
-
-
-

{activePage}

-

This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.

-
-
-

Key Objective

-

Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.

-
-
-

Success Metric

-

Achieve client alignment before the first break in the agenda.

-
-
-

1. Executive Summary

-

The imperative here is not just structural elegance, but actionable intelligence. When framing the issue, prioritise the financial delta and the organisational capacity required to capture it.

-
    -
  • Identify the core constraint (Capital, Talent, or Regulatory).
  • -
  • Map the adjacent capabilities required.
  • -
  • Draft the 100-day execution roadmap.
  • -
-
-
-
-
-
- ); -}; - -/* ========================================================================== - MAIN APP — Audio state managed with refs to avoid constant re-renders - ========================================================================== */ - -export default function App() { - // ── React state — only what MUST cause re-renders ──────────────────────── - const [activeView, setActiveView] = useState('missionControl'); - const [activeTrack, setActiveTrack] = useState(null); // track object | null - const [trackIndex, setTrackIndex] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(false); - - // ── Refs — updated without triggering re-renders ───────────────────────── - const utteranceRef = useRef(null); - const ttsIntervalRef = useRef(null); - const ttsStartTimeRef = useRef(0); - const ttsElapsedRef = useRef(0); - const durationRef = useRef(0); // estimated total duration (seconds) - const activeTrackRef = useRef(null); // mirrors activeTrack - - // DOM refs for live progress updates (direct DOM writes — no re-render) - const desktopProgressRef = useRef(null); // sidebar progress bar fill - const mobileProgressRef = useRef(null); // mobile strip progress bar fill - const desktopTimestampRef = useRef(null); // "00:25 / 14:20" - const mobileTimestampRef = useRef(null); // mobile "Now Playing · 00:25" - - // ── Direct DOM update helper ───────────────────────────────────────────── - const updateProgressDOM = useCallback((elapsed) => { - const dur = durationRef.current; - const pct = dur > 0 ? Math.min((elapsed / dur) * 100, 100) : 0; - const tStr = formatTime(elapsed); - const track = activeTrackRef.current; - - if (desktopProgressRef.current) desktopProgressRef.current.style.width = `${pct}%`; - if (mobileProgressRef.current) mobileProgressRef.current.style.width = `${pct}%`; - if (desktopTimestampRef.current) desktopTimestampRef.current.textContent = `${tStr} / ${track?.duration ?? ''}`; - if (mobileTimestampRef.current) mobileTimestampRef.current.textContent = tStr; - }, []); - - // ── TTS helpers ────────────────────────────────────────────────────────── - const stopTTS = useCallback(() => { - window.speechSynthesis.cancel(); - clearInterval(ttsIntervalRef.current); - utteranceRef.current = null; - }, []); - - const startTTS = useCallback((track, fromSeconds = 0) => { - if (!track?.ttsScript) return; - stopTTS(); - - const estimatedDuration = track.ttsScript.length / 12.5; - durationRef.current = estimatedDuration; - activeTrackRef.current = track; - - const charOffset = Math.floor((fromSeconds / estimatedDuration) * track.ttsScript.length); - const scriptSlice = track.ttsScript.slice(charOffset); - - const utt = new SpeechSynthesisUtterance(scriptSlice); - utt.rate = 0.92; utt.pitch = 1.0; utt.volume = 1.0; - const voices = window.speechSynthesis.getVoices(); - const preferred = voices.find((v) => v.lang.startsWith('en') && v.name.toLowerCase().includes('google')) - || voices.find((v) => v.lang.startsWith('en')) || null; - if (preferred) utt.voice = preferred; - - ttsStartTimeRef.current = Date.now(); - ttsElapsedRef.current = fromSeconds; - - utt.onend = () => { setIsPlaying(false); ttsElapsedRef.current = 0; clearInterval(ttsIntervalRef.current); updateProgressDOM(0); }; - utt.onerror = () => { setIsPlaying(false); clearInterval(ttsIntervalRef.current); }; - - utteranceRef.current = utt; - window.speechSynthesis.speak(utt); - setIsPlaying(true); - - // Interval only mutates DOM — NO setState, NO re-render - ttsIntervalRef.current = setInterval(() => { - const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000; - updateProgressDOM(Math.min(elapsed, estimatedDuration)); - }, 250); - }, [stopTTS, updateProgressDOM]); - - // ── Load a track ───────────────────────────────────────────────────────── - const loadTrack = useCallback((track) => { - const idx = MOCK_AUDIO_TRACKS.findIndex((t) => t.id === track.id); - stopTTS(); - setActiveTrack(track); - setTrackIndex(idx >= 0 ? idx : 0); - setIsPlaying(false); - ttsElapsedRef.current = 0; - durationRef.current = 0; - updateProgressDOM(0); - setTimeout(() => startTTS(track, 0), 200); - }, [stopTTS, startTTS, updateProgressDOM]); - - // ── Play / Pause ────────────────────────────────────────────────────────── - const togglePlay = useCallback(() => { - if (!activeTrackRef.current) return; - if (isPlaying) { - ttsElapsedRef.current = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000; - stopTTS(); - setIsPlaying(false); - } else { - startTTS(activeTrackRef.current, ttsElapsedRef.current); - } - }, [isPlaying, stopTTS, startTTS]); - - // ── Skip ───────────────────────────────────────────────────────────────── - const skipNext = useCallback(() => { - const next = (trackIndex + 1) % MOCK_AUDIO_TRACKS.length; - loadTrack(MOCK_AUDIO_TRACKS[next]); - }, [trackIndex, loadTrack]); - - const skipPrev = useCallback(() => { - const prev = (trackIndex - 1 + MOCK_AUDIO_TRACKS.length) % MOCK_AUDIO_TRACKS.length; - loadTrack(MOCK_AUDIO_TRACKS[prev]); - }, [trackIndex, loadTrack]); - - // ── Seek ────────────────────────────────────────────────────────────────── - const seek = useCallback((e) => { - const dur = durationRef.current; - if (!activeTrackRef.current || !dur) return; - const rect = e.currentTarget.getBoundingClientRect(); - const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const target = frac * dur; - ttsElapsedRef.current = target; - updateProgressDOM(target); - if (isPlaying) { stopTTS(); setTimeout(() => startTTS(activeTrackRef.current, target), 100); } - }, [isPlaying, stopTTS, startTTS, updateProgressDOM]); - - // Cleanup - useEffect(() => () => { stopTTS(); }, [stopTTS]); - - const handleNavClick = (id) => { setActiveView(id); setSidebarOpen(false); }; - - const navItems = [ - { id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard }, - { id: 'simulator', label: 'AI Co-Pilot', icon: MessageSquareQuote }, - { id: 'audio', label: 'Audio Library', icon: Headphones }, - { id: 'wiki', label: 'Loop Wiki', icon: BookOpen }, - ]; - - // ── Render ──────────────────────────────────────────────────────────────── - return ( -
- - {/* Mobile backdrop */} - {sidebarOpen && ( -
setSidebarOpen(false)} /> - )} - - {/* Sidebar */} -
-
-
-
-
- -
- FALAH -
-

Coach Portal

-
- -
- - - - {/* Sidebar audio player — inlined, refs for live progress (no re-render) */} -
- {activeTrack ? ( -
-
- - Now Playing - - {/* timestamp updated directly via ref — no re-render */} - 00:00 / {activeTrack.duration} -
-

{activeTrack.title}

-

{activeTrack.author}

- {/* Seekable progress bar — fill updated via ref */} -
-
-
-
- - - -
-
- ) : ( -
- -

Select an audio brief to begin

-
- )} -
-
- - {/* Main content column */} -
- - {/* Mobile top bar */} -
- -
-
- -
- FALAH - Coach Portal -
- {activeTrack ? ( - - ) :
} -
- - {/* Scrollable main */} -
-
-
- {activeView === 'missionControl' && } - {activeView === 'simulator' && } - {activeView === 'audio' && } - {activeView === 'wiki' && } -
-
- - {/* Mobile mini audio strip (only shown when track active) */} - {activeTrack && ( -
- {/* Mobile progress bar fill updated via ref */} -
-
-
-
-
-

{activeTrack.title}

-

- {isPlaying ? 'Now Playing' : 'Paused'} · 00:00 -

-
-
- - - -
-
+ {engagement && ( +
+
Active Engagement
+
{engagement.client}
+
{engagement.name}
)} +
+ + {/* Nav items */} + + + {/* Persona switcher */} +
+
+ + + {personaOpen && ( +
+
Switch Persona
+ {PERSONAS.map(p => ( + + ))} +
+ )} +
+
+ + ); + + return ( +
+ {/* ── Desktop sidebar ── */} + + + {/* ── Mobile sidebar backdrop ── */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + {/* ── Mobile sidebar ── */} + + + {/* ── Main content ── */} +
+ {/* Mobile top bar */} +
+ + Falah Coach Portal +
+ + {/* View content */} +
+ {renderView()} +
{/* Mobile bottom nav */} -