diff --git a/.env.example b/.env.example index 02af3a50..068651c6 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ # Get yours at: https://app.kapa.ai/admin PUBLIC_KAPA_INTEGRATION_ID= +# Optional custom handoff webhook target for the Ask AI "Create ticket" flow. +# If unset, handoff submissions run in preview mode and are logged server-side. +# If set, docs will POST full transcript payloads to this URL. +KAPA_HANDOFF_WEBHOOK_URL= + # RudderStack analytics — required for docs_404 event tracking and page analytics. # Without these, no analytics events are sent and the weekly 404 monitor will # report zero data. Set both in the Vercel project environment (all environments). diff --git a/src/components/KapaChatLauncher.css b/src/components/KapaChatLauncher.css index 3aca6dc6..562c1aa7 100644 --- a/src/components/KapaChatLauncher.css +++ b/src/components/KapaChatLauncher.css @@ -338,6 +338,146 @@ cursor: not-allowed; } +.sl-kapa-handoff { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.sl-kapa-handoff__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + height: 2.5rem; + padding: 0 0.875rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-sm); + background: var(--sl-color-black); + color: var(--sl-color-gray-1); + font: inherit; + font-size: var(--sl-text-sm); + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; +} + +.sl-kapa-handoff__toggle svg { + width: 0.95rem; + height: 0.95rem; + flex: none; +} + +.sl-kapa-handoff__toggle:hover { + border-color: var(--warp-control-border-hover); + background: var(--warp-control-bg-hover); + color: var(--sl-color-white); +} + +.sl-kapa-handoff__toggle:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 2px; +} + +.sl-kapa-handoff__form { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-lg); + background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 28%); +} + +.sl-kapa-handoff__form label { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sl-kapa-handoff__form label span { + font-size: var(--sl-text-2xs); + color: var(--sl-color-gray-3); +} + +.sl-kapa-handoff__form input, +.sl-kapa-handoff__form textarea { + width: 100%; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-sm); + background: var(--sl-color-black); + color: var(--sl-color-white); + padding: 0.5rem 0.625rem; + font: inherit; + font-size: var(--sl-text-sm); +} + +.sl-kapa-handoff__form textarea { + resize: vertical; + min-height: 4rem; +} + +.sl-kapa-handoff__form input::placeholder, +.sl-kapa-handoff__form textarea::placeholder { + color: var(--sl-color-gray-3); +} + +.sl-kapa-handoff__form input:focus-visible, +.sl-kapa-handoff__form textarea:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 2px; +} + +.sl-kapa-handoff__submit { + margin-top: 0.25rem; + height: 2.5rem; + border: 1px solid var(--sl-color-text-accent); + border-radius: var(--sl-radius-sm); + background: var(--sl-color-text-accent); + color: var(--sl-color-black); + font: inherit; + font-size: var(--sl-text-sm); + font-weight: 600; + cursor: pointer; + transition: + border-color 0.15s ease, + background-color 0.15s ease; +} + +.sl-kapa-handoff__submit:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%); + background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%); +} + +.sl-kapa-handoff__submit:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 2px; +} + +.sl-kapa-handoff__submit:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.sl-kapa-handoff__status { + margin: 0; + font-size: var(--sl-text-xs); + line-height: 1.4; +} + +.sl-kapa-handoff__status--success { + color: #86efac; +} + +.sl-kapa-handoff__status--error { + color: #fecaca; +} + /* Hide the reCAPTCHA badge */ .grecaptcha-badge { visibility: hidden; diff --git a/src/components/KapaChatLauncher.tsx b/src/components/KapaChatLauncher.tsx index d4e1bcb9..32264e19 100644 --- a/src/components/KapaChatLauncher.tsx +++ b/src/components/KapaChatLauncher.tsx @@ -11,6 +11,7 @@ import { LuMessageSquare, LuSend, LuSquarePen, + LuTicket, LuThumbsDown, LuThumbsUp, LuX, @@ -32,6 +33,12 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const [isOpen, setIsOpen] = useState(autoOpen); const [query, setQuery] = useState(''); const [hasStartedConversation, setHasStartedConversation] = useState(false); + const [showHandoffForm, setShowHandoffForm] = useState(false); + const [handoffEmail, setHandoffEmail] = useState(''); + const [handoffNote, setHandoffNote] = useState(''); + const [isSubmittingHandoff, setIsSubmittingHandoff] = useState(false); + const [handoffStatus, setHandoffStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [handoffStatusMessage, setHandoffStatusMessage] = useState(''); const [isAppleDevice, setIsAppleDevice] = useState(false); const messagesRef = useRef(null); const dialogRef = useRef(null); @@ -45,6 +52,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio isGeneratingAnswer, isPreparingAnswer, submitQuery, + threadId, } = useChat(); useEffect(() => { setIsAppleDevice(isMac()); @@ -121,6 +129,60 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio addFeedback(questionAnswerId, reaction); }; + const buildConversationTranscript = () => { + if (!conversation.length) return 'No conversation history yet.'; + return conversation + .map((qa, index) => { + const sources = qa.sources?.length + ? `\nSources:\n${qa.sources.map((source) => `- ${source.title}: ${source.source_url}`).join('\n')}` + : ''; + return `Q${index + 1}: ${qa.question}\nA${index + 1}: ${qa.answer || '(no answer generated yet)'}${sources}`; + }) + .join('\n\n'); + }; + + const submitHandoff = async (event: FormEvent) => { + event.preventDefault(); + if (isSubmittingHandoff) return; + setIsSubmittingHandoff(true); + setHandoffStatus('idle'); + setHandoffStatusMessage(''); + try { + const response = await fetch('/kapa-handoff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + replyToEmail: handoffEmail.trim(), + note: handoffNote.trim(), + pageUrl: window.location.href, + threadId, + conversationCount: conversation.length, + conversationTranscript: buildConversationTranscript(), + askedAt: new Date().toISOString(), + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.error || 'Unable to submit handoff request.'); + } + setHandoffStatus('success'); + setHandoffStatusMessage( + data?.mode === 'preview' + ? 'Ticket preview captured. Configure webhook env vars to send live emails.' + : 'Ticket submitted. A team member can follow up via your email.' + ); + setHandoffNote(''); + setShowHandoffForm(false); + } catch (submitError) { + setHandoffStatus('error'); + setHandoffStatusMessage( + submitError instanceof Error ? submitError.message : 'Unable to submit handoff request.' + ); + } finally { + setIsSubmittingHandoff(false); + } + }; + const openPanel = () => { // If the Pagefind search dialog is open with a query typed in, hand // that query off to Kapa's input. Both entry points (the ⌘+I / Ctrl+I @@ -304,6 +366,56 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio