From 8ee0cd750857da8f2f33cec595527355737af982 Mon Sep 17 00:00:00 2001 From: snowsecure Date: Sat, 20 Dec 2025 22:11:53 -0600 Subject: [PATCH] Ensure custom scenario opens modal instead of inline form --- src/app/globals.css | 230 +++++++++++++++++++++++++++++ src/app/layout.tsx | 13 +- src/app/login/page.tsx | 11 +- src/components/FeedbackApp.tsx | 141 ++++++++++++++++-- src/components/SailAttribution.tsx | 61 ++++++++ src/components/ScenarioPanel.tsx | 192 ++++++++++++++++++++++-- src/lib/customScenario.ts | 8 + 7 files changed, 613 insertions(+), 43 deletions(-) create mode 100644 src/components/SailAttribution.tsx create mode 100644 src/lib/customScenario.ts diff --git a/src/app/globals.css b/src/app/globals.css index f35fb08..22caaf4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -404,6 +404,236 @@ select:focus { color: var(--primary); } +.sail-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.9rem; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + color: var(--text-secondary); + font-weight: 700; + letter-spacing: 0.08em; + font-size: 0.75rem; + text-transform: uppercase; + cursor: pointer; + transition: var(--transition); + box-shadow: var(--shadow-sm); +} + +.sail-badge:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + color: var(--text-primary); +} + +.sail-badge--large { + padding: 0.85rem 1.15rem; + font-size: 0.85rem; + letter-spacing: 0.1em; +} + +.sail-emoji { + font-size: 1.25rem; +} + +.sail-text { + white-space: nowrap; +} + +.sail-modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 50; +} + +.sail-modal-card { + position: relative; + width: min(360px, 90vw); + background: #ffffff; + border-radius: 16px; + padding: 2.25rem 2rem; + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.5rem; +} + +.sail-modal-close { + position: absolute; + top: 12px; + right: 12px; + background: transparent; + border: none; + font-size: 1.25rem; + color: var(--text-tertiary); + cursor: pointer; +} + +.sail-modal-emoji { + font-size: 3rem; + line-height: 1; +} + +.sail-modal-subtitle { + font-size: 0.8rem; + letter-spacing: 0.12em; + font-weight: 700; + color: var(--text-secondary); + margin-top: 0.35rem; +} + +.sail-modal-title { + font-size: 1.35rem; + margin: 0; + color: var(--text-primary); + font-weight: 800; +} + +.sail-modal-email { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.5rem; + color: var(--primary); + font-weight: 600; + border: 1px solid var(--border); + padding: 0.65rem 1rem; + border-radius: 10px; + text-decoration: none; + background: #f8fafc; +} + +.sail-modal-email:hover { + background: var(--primary-light); +} + +.scenario-modal-overlay { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.5); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 60; +} + +.scenario-modal { + width: min(760px, 95vw); + max-height: 90vh; + overflow: auto; + background: #fff; + border-radius: 18px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18); + padding: 1.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.scenario-modal__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.scenario-modal__eyebrow { + font-size: 0.78rem; + letter-spacing: 0.12em; + font-weight: 700; + color: var(--text-secondary); + margin: 0; + text-transform: uppercase; +} + +.scenario-modal__title { + margin: 0.15rem 0 0; + font-size: 1.2rem; + color: var(--text-primary); +} + +.scenario-modal__close { + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: 50%; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + cursor: pointer; + color: var(--text-secondary); +} + +.scenario-modal__helper { + margin: 0; + color: var(--text-secondary); +} + +.scenario-modal__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.scenario-modal__label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-weight: 700; + color: var(--text-primary); + font-size: 0.95rem; +} + +.scenario-modal__label input, +.scenario-modal__label textarea { + background: var(--surface-alt); +} + +.scenario-modal__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.scenario-modal__error { + color: var(--error); + background: #fef2f2; + border: 1px solid #fecaca; + padding: 0.75rem 1rem; + border-radius: 10px; + font-weight: 600; +} + +.scenario-modal__actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.scenario-modal__tip { + margin: 0.25rem 0 0.1rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.required { + color: var(--error); +} + /* Responsive */ @media (max-width: 1280px) { .app-layout { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c2295d3..15d3372 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Inter } from "next/font/google"; import Image from "next/image"; import DashboardButton from "@/components/DashboardButton"; import "./globals.css"; +import SailAttribution from "@/components/SailAttribution"; const inter = Inter({ subsets: ["latin"] }); @@ -36,17 +37,9 @@ export default function RootLayout({

Feedback Coaching

-
+
- - SAIL logo - +
{children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 4898729..379f4e6 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import Image from 'next/image'; +import SailAttribution from '@/components/SailAttribution'; export default function LoginPage() { const [username, setUsername] = useState(''); @@ -53,14 +53,7 @@ export default function LoginPage() { backdropFilter: 'blur(10px)' }}>
- Sailboat +
diff --git a/src/components/FeedbackApp.tsx b/src/components/FeedbackApp.tsx index ee81a14..191b4ad 100644 --- a/src/components/FeedbackApp.tsx +++ b/src/components/FeedbackApp.tsx @@ -6,28 +6,119 @@ import ChatPanel from './ChatPanel'; import CoachingPanel from './CoachingPanel'; import { Scenario, Difficulty, getRandomScenario } from '@/lib/scenarios'; import { Message, CoachingResult } from '@/lib/openai'; +import { CustomScenarioDetails } from '@/lib/customScenario'; + +const difficultyOptions: Difficulty[] = ['Basic', 'Moderate', 'Advanced']; export default function FeedbackApp() { const [scenario, setScenario] = useState(null); const [difficulty, setDifficulty] = useState('Basic'); + const [scenarioMode, setScenarioMode] = useState<'preset' | 'custom'>('preset'); + const [customScenario, setCustomScenario] = useState({ + title: '', + context: '', + issue: '', + employeeName: '', + employeeRole: '', + personaTraits: '', + }); const [messages, setMessages] = useState([]); const [coaching, setCoaching] = useState(null); const [status, setStatus] = useState<'idle' | 'active' | 'coaching' | 'completed'>('idle'); const [isTyping, setIsTyping] = useState(false); + const [isCustomModalOpen, setIsCustomModalOpen] = useState(false); const messagesRef = useRef([]); const isTypingRef = useRef(isTyping); - const handleDifficultyChange = (newDifficulty: Difficulty | 'Random') => { - setDifficulty(newDifficulty); - }; + const resolveDifficulty = useCallback((value: Difficulty | 'Random'): Difficulty => { + if (value === 'Random') { + const randomIndex = Math.floor(Math.random() * difficultyOptions.length); + return difficultyOptions[randomIndex]; + } + return value; + }, []); - const initializeScenario = useCallback(() => { - const newScenario = getRandomScenario(difficulty); + const buildCustomScenario = useCallback(( + diffOverride?: Difficulty | 'Random', + data?: CustomScenarioDetails + ): Scenario => { + const form = data ?? customScenario; + const resolvedDifficulty = resolveDifficulty(diffOverride ?? difficulty); + + return { + id: 'custom', + title: form.title.trim() || 'Custom Scenario', + difficulty: resolvedDifficulty, + context: form.context.trim() || 'Describe the setting for your conversation.', + employeeName: form.employeeName.trim() || 'Employee', + employeeRole: form.employeeRole.trim() || 'Team Member', + issue: form.issue.trim() || 'Describe the issue you want to practice discussing.', + personaTraits: form.personaTraits.trim() || 'Respond realistically based on the issue and difficulty level.', + }; + }, [customScenario, difficulty, resolveDifficulty]); + + const resetSessionWithScenario = useCallback((newScenario: Scenario, nextStatus: 'idle' | 'active' = 'idle') => { setScenario(newScenario); setMessages([]); setCoaching(null); - setStatus('idle'); - }, [difficulty]); + setStatus(nextStatus); + }, []); + + const handleDifficultyChange = (newDifficulty: Difficulty | 'Random') => { + if (status === 'active' && messages.length > 0) { + if (!confirm("Changing difficulty will reset the current session. Continue?")) { + return; + } + } + + setDifficulty(newDifficulty); + const nextScenario = scenarioMode === 'preset' + ? getRandomScenario(newDifficulty) + : buildCustomScenario(newDifficulty); + + resetSessionWithScenario(nextScenario); + }; + + const handleScenarioModeChange = (mode: 'preset' | 'custom') => { + if (mode !== 'preset') { + return; + } + + if (status === 'active' && messages.length > 0) { + if (!confirm("Switching scenario mode will reset the current session. Continue?")) { + return; + } + } + + setScenarioMode('preset'); + const nextScenario = getRandomScenario(difficulty); + resetSessionWithScenario(nextScenario); + }; + + const openCustomModal = () => { + if (status === 'active' && messages.length > 0) { + if (!confirm("Switching scenario mode will reset the current session. Continue?")) { + return; + } + setMessages([]); + setCoaching(null); + setStatus('idle'); + } + setScenarioMode('custom'); + setIsCustomModalOpen(true); + }; + + const handleSubmitCustomScenario = (formData: CustomScenarioDetails) => { + const nextScenario = buildCustomScenario(undefined, formData); + setCustomScenario(formData); + setScenarioMode('custom'); + resetSessionWithScenario(nextScenario); + setIsCustomModalOpen(false); + }; + + const handleCloseCustomModal = () => { + setIsCustomModalOpen(false); + }; const handleRegenerate = useCallback(() => { if (status === 'active' && messages.length > 0) { @@ -35,13 +126,17 @@ export default function FeedbackApp() { return; } } - initializeScenario(); - }, [status, messages.length, initializeScenario]); + const newScenario = scenarioMode === 'preset' + ? getRandomScenario(difficulty) + : buildCustomScenario(); + resetSessionWithScenario(newScenario); + }, [status, messages, scenarioMode, difficulty, resetSessionWithScenario, buildCustomScenario]); // Initialize with a scenario useEffect(() => { - initializeScenario(); - }, [initializeScenario]); + resetSessionWithScenario(getRandomScenario(difficulty)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { messagesRef.current = messages; @@ -51,7 +146,23 @@ export default function FeedbackApp() { isTypingRef.current = isTyping; }, [isTyping]); + const isCustomScenarioValid = Boolean( + customScenario.employeeName.trim() && + customScenario.context.trim() && + customScenario.issue.trim() + ); + const handleStart = () => { + if (scenarioMode === 'custom') { + if (!isCustomScenarioValid) { + alert("Please enter an employee name, scenario context, and issue to practice."); + return; + } + const customScenarioDetails = buildCustomScenario(); + resetSessionWithScenario(customScenarioDetails, 'active'); + return; + } + if (!scenario) return; setStatus('active'); }; @@ -150,11 +261,19 @@ export default function FeedbackApp() {
setIsOpen(false), []); + const openModal = () => setIsOpen(true); + + useEffect(() => { + if (!isOpen) return; + + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeModal(); + } + }; + + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [isOpen, closeModal]); + + return ( + <> + + + {isOpen && ( +
+
+ +
+ ⛵️ +
+

DEVELOPED BY

+

Stewart AI Lab

+ + psnowden@stewart.com + +
+
+ )} + + ); +} diff --git a/src/components/ScenarioPanel.tsx b/src/components/ScenarioPanel.tsx index 582968a..585fe73 100644 --- a/src/components/ScenarioPanel.tsx +++ b/src/components/ScenarioPanel.tsx @@ -1,23 +1,65 @@ import React from 'react'; import { Scenario, Difficulty } from '@/lib/scenarios'; +import { CustomScenarioDetails } from '@/lib/customScenario'; interface ScenarioPanelProps { scenario: Scenario | null; + scenarioMode: 'preset' | 'custom'; selectedDifficulty: Difficulty | 'Random'; + customScenario: CustomScenarioDetails; onDifficultyChange: (diff: Difficulty | 'Random') => void; + onScenarioModeChange: (mode: 'preset' | 'custom') => void; + onOpenCustomModal: () => void; + onCloseCustomModal: () => void; + onSubmitCustomScenario: (data: CustomScenarioDetails) => void; + isCustomModalOpen: boolean; onRegenerate: () => void; onStart: () => void; isSessionActive: boolean; + isCustomScenarioValid: boolean; } export default function ScenarioPanel({ scenario, + scenarioMode, selectedDifficulty, + customScenario, onDifficultyChange, + onScenarioModeChange, + onOpenCustomModal, + onCloseCustomModal, + onSubmitCustomScenario, + isCustomModalOpen, onRegenerate, onStart, isSessionActive, + isCustomScenarioValid, }: ScenarioPanelProps) { + const scenarioToDisplay = scenario; + + const primaryActionLabel = scenarioMode === 'custom' + ? (isSessionActive ? 'Reset & Apply Custom Scenario' : 'Use Custom Scenario') + : (isSessionActive ? 'Reset & Regenerate' : 'Regenerate Scenario'); + + const [formState, setFormState] = React.useState(customScenario); + const [error, setError] = React.useState(''); + + React.useEffect(() => { + if (isCustomModalOpen) { + setFormState(customScenario); + setError(''); + } + }, [isCustomModalOpen, customScenario]); + + const handleCustomSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!formState.employeeName.trim() || !formState.context.trim() || !formState.issue.trim()) { + setError('Please fill in Employee Name, Scenario / Context, and Issue to Address.'); + return; + } + onSubmitCustomScenario(formState); + }; + return (
@@ -41,18 +83,44 @@ export default function ScenarioPanel({
- {scenario ? ( +
+

Scenario Source

+
+ + +
+
+ + {scenarioToDisplay ? (

Scenario

- {scenario.difficulty} + {scenarioToDisplay.difficulty}
-

{scenario.title}

+

{scenarioToDisplay.title}

- {scenario.context} + {scenarioToDisplay.context}

@@ -60,29 +128,34 @@ export default function ScenarioPanel({

Employee

- {scenario.employeeName.charAt(0)} + {scenarioToDisplay.employeeName.charAt(0)}
- {scenario.employeeName} + {scenarioToDisplay.employeeName} - {scenario.employeeRole} + {scenarioToDisplay.employeeRole}
-

- “{scenario.personaTraits}” +

+ “{scenarioToDisplay.personaTraits}”

The Issue

- {scenario.issue} + {scenarioToDisplay.issue}
{!isSessionActive && ( - )} @@ -90,19 +163,112 @@ export default function ScenarioPanel({ className="btn btn-secondary" onClick={onRegenerate} style={{ width: '100%' }} + disabled={scenarioMode === 'custom' && !isCustomScenarioValid} > - {isSessionActive ? 'Reset & Regenerate' : 'Regenerate Scenario'} + {primaryActionLabel}
) : (
-

Select a difficulty and click Regenerate to start.

+

Select a difficulty and click Regenerate to start, or switch to a custom scenario above.

)} + + {isCustomModalOpen && ( +
+
+
+
+

Your Scenario Details

+

Enter details for your real conversation

+
+ +
+

+ Enter the same details as our presets to practice a real upcoming conversation. +

+ + +
+ + +
+