import type { ActionConfirmation, CalendlyBookingSlot, CalendlyScheduledEvent, WidgetTheme, } from "./public-contracts.js"; import type { AccessibilityCapabilities } from "./accessibility/types.js"; import { createBookingOfferCard } from "./booking-view.js"; import type { ConversationMessage, ConversationState, ExternalAction, } from "./conversation.js"; import { createMessageMarkdown } from "./message-markdown.js"; import { createPlaybookCard } from "./playbook-view.js"; import type { SubmitPlaybookFieldsRequest } from "./playbook-contracts.js"; import { surfaceStyles } from "./styles.js"; import { applyWidgetTheme } from "./theme.js"; export type SurfaceKind = "embed" | "inline"; export interface SurfaceViewHandlers { onAbort(): void; onConfirmAction(): void; onCancelAction(): void; onCompleteExternalAction(event: CalendlyScheduledEvent): void; onSelectBookingSlot(slot: CalendlyBookingSlot): void; onSubmitPlaybookFields( step: SubmitPlaybookFieldsRequest["step"], values: SubmitPlaybookFieldsRequest["values"], ): void; onSubmitBookingDetails(details: { name: string; email: string; timezone: string; }): void; onFeedback(resolved: boolean): void; onResetConversation(): void; onSend(message: string): void; } /** * Owns the stable Shadow DOM and translates controller snapshots into visible * UI. It intentionally contains no API key, fetch logic, or tenant decisions. */ export class SurfaceView { private readonly root: ShadowRoot; private readonly kind: SurfaceKind; private readonly surface: HTMLElement; private readonly conversation: HTMLElement; private readonly messages: HTMLElement; private readonly composer: HTMLFormElement; private readonly textarea: HTMLTextAreaElement; private readonly sendButton: HTMLButtonElement; private readonly sendIcon: HTMLElement; private readonly speechButton: HTMLButtonElement; private readonly status: HTMLElement; private readonly error: HTMLElement; private readonly dialog: HTMLDialogElement | undefined; private readonly dialogComposerSlot: HTMLElement | undefined; private readonly resumeButton: HTMLButtonElement | undefined; private capabilities: AccessibilityCapabilities = {}; private handlers: SurfaceViewHandlers | undefined; private externalAction: ExternalAction | null = null; private hasBlockingInteraction = false; private isHistoryLoading = false; private isStreaming = false; private speechStop: (() => void) | undefined; constructor(root: ShadowRoot, kind: SurfaceKind) { this.root = root; this.kind = kind; const style = document.createElement("style"); style.textContent = surfaceStyles; this.surface = createSurface(kind); this.conversation = createElement("div", "conversation"); const conversationInner = createElement("div", "conversation-inner"); this.messages = createElement("div", "messages"); this.messages.setAttribute("role", "log"); this.messages.setAttribute("aria-live", "polite"); this.messages.setAttribute("aria-relevant", "additions text"); conversationInner.append(this.messages); this.conversation.append(conversationInner); const composerElements = createComposer(kind); this.composer = composerElements.composer; this.textarea = composerElements.textarea; this.sendButton = composerElements.sendButton; this.sendIcon = composerElements.sendIcon; this.speechButton = composerElements.speechButton; this.status = createElement("div", "status"); this.status.setAttribute("role", "status"); this.status.setAttribute("aria-live", "polite"); this.error = createElement("div", "error"); this.error.setAttribute("role", "alert"); this.error.hidden = true; if (kind === "embed") { const dialogElements = createEmbedDialog( this.conversation, this.status, this.error, ); this.dialog = dialogElements.dialog; this.dialogComposerSlot = dialogElements.composerSlot; this.resumeButton = createResumeButton(); this.composer.append(this.resumeButton); this.surface.append(this.composer); this.root.append(style, this.surface, this.dialog); this.bindDialogEvents( dialogElements.closeButton, dialogElements.resetButton, ); } else { this.dialog = undefined; this.dialogComposerSlot = undefined; this.resumeButton = undefined; this.surface.append( this.conversation, this.composer, this.status, this.error, ); this.root.append(style, this.surface); } this.bindComposerEvents(); window.addEventListener("message", this.handleCalendlyMessage); } setHandlers(handlers: SurfaceViewHandlers): void { this.handlers = handlers; } setCapabilities(capabilities: AccessibilityCapabilities): void { this.capabilities = capabilities; this.speechButton.hidden = capabilities.speechToText === undefined; } setTheme(theme: WidgetTheme): void { applyWidgetTheme(this.root.host as HTMLElement, theme); this.textarea.placeholder = theme.placeholder; } /** Applies one immutable controller snapshot to the existing controls. */ update(state: ConversationState): void { const wasHistoryLoading = this.isHistoryLoading; this.isHistoryLoading = state.isHistoryLoading; const isPlaybookBlocking = state.playbookRequest !== null && state.playbookRequest.kind !== "handoff_result"; this.hasBlockingInteraction = state.pendingAction !== null || state.externalAction !== null || isPlaybookBlocking; this.isStreaming = state.isStreaming; this.externalAction = state.externalAction; this.renderMessages( state.messages, state.isStreaming, state.feedback, state.pendingAction, state.externalAction, state.bookingOffer, state.selectedBookingSlot, state.playbookRequest, state.isActionSubmitting, ); const hasConversation = state.messages.length > 0 || state.isHistoryLoading; this.conversation.classList.toggle( "conversation--open", this.kind === "embed" || hasConversation, ); this.textarea.disabled = state.isStreaming || state.pendingAction !== null || state.externalAction !== null || isPlaybookBlocking || state.isActionSubmitting; this.sendButton.type = state.isStreaming ? "button" : "submit"; this.sendButton.setAttribute( "aria-label", state.isStreaming ? "Interrompi risposta" : "Invia messaggio", ); this.sendIcon.textContent = state.isStreaming ? "■" : "↑"; this.status.textContent = state.isHistoryLoading ? "Recupero la conversazione…" : state.isStreaming ? "Akintu sta rispondendo… Premi Esc per interrompere." : ""; this.error.hidden = state.error === null; this.error.textContent = state.error ?? ""; this.updateResumeButton(); if ( wasHistoryLoading && !state.isHistoryLoading && this.hasBlockingInteraction ) { this.openOverlay(); } } destroy(): void { this.speechStop?.(); this.capabilities.textToSpeech?.cancel(); window.removeEventListener("message", this.handleCalendlyMessage); this.closeOverlay(false); } private bindComposerEvents(): void { this.composer.addEventListener("submit", (event) => this.handleSubmit(event), ); this.sendButton.addEventListener("click", (event) => { if (!this.isStreaming) return; event.preventDefault(); this.handlers?.onAbort(); }); this.textarea.addEventListener("keydown", (event) => this.handleKeyDown(event), ); this.speechButton.addEventListener("click", () => this.handleSpeech()); this.resumeButton?.addEventListener("click", () => this.openOverlay()); } private bindDialogEvents( closeButton: HTMLButtonElement, resetButton: HTMLButtonElement, ): void { closeButton.addEventListener("click", () => this.closeOverlay(true)); resetButton.addEventListener("click", () => { if ( window.confirm( "Vuoi iniziare una nuova conversazione? La cronologia attuale non sarà più mostrata in questa chat.", ) ) { this.handlers?.onResetConversation(); } }); this.dialog?.addEventListener("cancel", (event) => { event.preventDefault(); if (this.isStreaming) this.handlers?.onAbort(); this.closeOverlay(true); }); this.dialog?.addEventListener("close", () => this.restoreCompactComposer(false), ); } private handleSubmit(event: SubmitEvent): void { event.preventDefault(); if (this.isStreaming) return; const message = this.textarea.value.trim(); if (message.length === 0) return; if (this.kind === "embed") this.openOverlay(); this.textarea.value = ""; this.handlers?.onSend(message); } private handleKeyDown(event: KeyboardEvent): void { if (event.key === "Escape") { this.speechStop?.(); if (this.isStreaming) this.handlers?.onAbort(); return; } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); this.textarea.form?.requestSubmit(); } } private openOverlay(): void { if (this.dialog === undefined || this.dialogComposerSlot === undefined) { return; } if (this.resumeButton !== undefined) this.resumeButton.hidden = true; this.dialogComposerSlot.append(this.composer); if (this.dialog.open) return; if (typeof this.dialog.showModal === "function") { this.dialog.showModal(); } else { this.dialog.setAttribute("open", ""); } this.textarea.focus(); } private closeOverlay(shouldRestoreFocus: boolean): void { if (this.dialog === undefined) return; if (this.dialog.open && typeof this.dialog.close === "function") { this.dialog.close(); } else { this.dialog.removeAttribute("open"); } this.restoreCompactComposer(shouldRestoreFocus); } private restoreCompactComposer(shouldRestoreFocus: boolean): void { if (this.kind !== "embed") return; if (this.composer.parentElement !== this.surface) { this.surface.append(this.composer); } this.updateResumeButton(); if (shouldRestoreFocus && !this.textarea.disabled) this.textarea.focus(); } private updateResumeButton(): void { if (this.resumeButton === undefined) return; const shouldShow = this.hasBlockingInteraction && this.dialog?.open !== true; this.resumeButton.hidden = !shouldShow; this.resumeButton.textContent = this.externalAction === null ? "Riprendi richiesta" : "Riprendi prenotazione"; } private handleSpeech(): void { const speechToText = this.capabilities.speechToText; if (speechToText === undefined || this.speechStop !== undefined) return; this.speechButton.classList.add("speech-button--active"); this.speechButton.textContent = "Ascolto…"; const finish = (): void => { this.speechStop = undefined; this.speechButton.classList.remove("speech-button--active"); this.speechButton.textContent = "Voce"; }; const recognition = speechToText.start({ onTranscript: (transcript) => { this.textarea.value = transcript; this.textarea.focus(); }, onError: () => { this.status.textContent = "Il riconoscimento vocale non è disponibile. Puoi continuare scrivendo."; finish(); }, onEnd: finish, }); this.speechStop = recognition === undefined ? undefined : recognition.stop; if (recognition === undefined) finish(); } private readonly handleCalendlyMessage = ( event: MessageEvent, ): void => { const action = this.externalAction; if (action === null) return; if (event.origin !== new URL(action.schedulingUrl).origin) return; const scheduledEvent = parseCalendlyScheduledEvent(event.data); if (scheduledEvent === undefined) return; this.handlers?.onCompleteExternalAction(scheduledEvent); }; private renderMessages( messages: ConversationMessage[], isStreaming: boolean, feedback: ConversationState["feedback"], pendingAction: ActionConfirmation | null, externalAction: ExternalAction | null, bookingOffer: ConversationState["bookingOffer"], selectedBookingSlot: ConversationState["selectedBookingSlot"], playbookRequest: ConversationState["playbookRequest"], isActionSubmitting: boolean, ): void { this.messages.replaceChildren(); messages.forEach((message, index) => { const wrapper = createElement( "article", `message message--${message.role}`, ); wrapper.setAttribute( "aria-label", message.role === "user" ? "Tu" : "Akintu", ); const text = document.createElement("div"); text.className = message.role === "assistant" ? "message-content message-content--formatted" : "message-content"; const visibleContent = message.content || (isStreaming && index === messages.length - 1 ? "Sto preparando la risposta…" : ""); if (message.role === "assistant") { text.append(createMessageMarkdown(visibleContent)); } else { text.textContent = visibleContent; } wrapper.append(text); if ( message.role === "assistant" && message.content.length > 0 && this.capabilities.textToSpeech !== undefined ) { const actions = createElement("div", "message-actions"); const listenButton = document.createElement("button"); listenButton.type = "button"; listenButton.className = "listen-button"; listenButton.textContent = "Ascolta la risposta"; listenButton.addEventListener("click", () => this.capabilities.textToSpeech?.speak(message.content), ); actions.append(listenButton); wrapper.append(actions); } if ( message.role === "assistant" && message.content.length > 0 && index === messages.length - 1 && !isStreaming && externalAction === null ) { wrapper.append(this.createFeedbackActions(feedback)); } this.messages.append(wrapper); }); if (pendingAction !== null) { this.messages.append( this.createActionConfirmation(pendingAction, isActionSubmitting), ); } if (bookingOffer !== null && externalAction === null) { const booking = createBookingOfferCard({ offer: bookingOffer, selectedSlot: selectedBookingSlot, isSubmitting: isActionSubmitting, onSelectSlot: (slot) => this.handlers?.onSelectBookingSlot(slot), onSubmitDetails: (details) => this.handlers?.onSubmitBookingDetails(details), }); this.messages.append(booking); } if (playbookRequest !== null) { this.messages.append( createPlaybookCard({ request: playbookRequest, isSubmitting: isActionSubmitting, onSubmit: (step, values) => this.handlers?.onSubmitPlaybookFields(step, values), }), ); } if (externalAction !== null) { const booking = this.createCalendlyBooking(externalAction); this.messages.append(booking); this.messages.scrollTop = booking.offsetTop; return; } this.messages.scrollTop = this.messages.scrollHeight; } private createCalendlyBooking(action: ExternalAction): HTMLElement { const card = createElement("section", "calendly-booking"); card.setAttribute("aria-label", "Prenotazione Calendly"); const title = createElement("strong", "calendly-booking-title"); title.textContent = `Conferma ${action.eventTypeName}`; const explanation = createElement("p", "calendly-booking-description"); explanation.textContent = "I dati sono già precompilati. Conferma su Calendly: Akintu verificherà automaticamente l’esito."; const frame = document.createElement("iframe"); frame.className = "calendly-booking-frame"; frame.title = `Calendly · ${action.eventTypeName}`; frame.loading = "eager"; frame.referrerPolicy = "strict-origin-when-cross-origin"; const url = new URL(action.schedulingUrl); url.searchParams.set("embed_type", "Inline"); url.searchParams.set("embed_domain", window.location.hostname); url.searchParams.set("name", action.inviteeName); url.searchParams.set("email", action.inviteeEmail); url.searchParams.set("utm_source", "akintu"); url.searchParams.set("utm_medium", "chat"); url.searchParams.set("utm_campaign", "booking"); url.searchParams.set("utm_content", action.actionId); frame.src = url.toString(); const fallback = document.createElement("a"); fallback.className = "calendly-booking-fallback"; fallback.href = url.toString(); fallback.target = "_blank"; fallback.rel = "noopener noreferrer"; fallback.textContent = "Apri Calendly in una nuova scheda"; card.append(title, explanation, frame, fallback); return card; } private createActionConfirmation( action: ActionConfirmation, isSubmitting: boolean, ): HTMLElement { const card = createElement("section", "action-confirmation"); card.setAttribute("aria-label", "Conferma azione"); const title = createElement("strong", "action-confirmation-title"); title.textContent = "Conferma richiesta"; const summary = createElement("p", "action-confirmation-summary"); summary.textContent = action.summary; const actions = createElement("div", "action-confirmation-actions"); const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "action-confirm-button"; confirm.disabled = isSubmitting; confirm.textContent = isSubmitting ? "Esecuzione…" : "Conferma"; confirm.addEventListener("click", () => this.handlers?.onConfirmAction()); const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "action-cancel-button"; cancel.disabled = isSubmitting; cancel.textContent = "Annulla"; cancel.addEventListener("click", () => this.handlers?.onCancelAction()); actions.append(confirm, cancel); card.append(title, summary, actions); return card; } private createFeedbackActions( feedback: ConversationState["feedback"], ): HTMLElement { const actions = createElement("div", "feedback-actions"); if (feedback !== null) { actions.textContent = "Grazie per il feedback."; return actions; } const label = createElement("span", "feedback-label"); label.textContent = "Hai risolto?"; const yes = document.createElement("button"); yes.type = "button"; yes.className = "feedback-button"; yes.textContent = "Sì"; yes.addEventListener("click", () => this.handlers?.onFeedback(true)); const no = document.createElement("button"); no.type = "button"; no.className = "feedback-button"; no.textContent = "No"; no.addEventListener("click", () => this.handlers?.onFeedback(false)); actions.append(label, yes, no); return actions; } } function parseCalendlyScheduledEvent( value: unknown, ): CalendlyScheduledEvent | undefined { if ( !isRecord(value) || value.event !== "calendly.event_scheduled" || !isRecord(value.payload) ) { return undefined; } const eventResource = value.payload.event; const inviteeResource = value.payload.invitee; if (!isRecord(eventResource) || !isRecord(inviteeResource)) return undefined; const eventUri = eventResource.uri; const inviteeUri = inviteeResource.uri; if ( typeof eventUri !== "string" || typeof inviteeUri !== "string" || !areCalendlyResourceUrisValid(eventUri, inviteeUri) ) { return undefined; } return { eventUri, inviteeUri }; } function areCalendlyResourceUrisValid( eventUri: string, inviteeUri: string, ): boolean { try { const eventUrl = new URL(eventUri); const inviteeUrl = new URL(inviteeUri); const eventSegments = eventUrl.pathname.split("/").filter(Boolean); const inviteeSegments = inviteeUrl.pathname.split("/").filter(Boolean); return ( eventUrl.origin === "https://api.calendly.com" && inviteeSegments[0] === "scheduled_events" && inviteeUrl.origin === eventUrl.origin && eventSegments.length === 2 && eventSegments[0] === "scheduled_events" && inviteeSegments.length === 4 && inviteeSegments[1] === eventSegments[1] && inviteeSegments[2] === "invitees" ); } catch { return false; } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function createComposer(kind: SurfaceKind): { composer: HTMLFormElement; textarea: HTMLTextAreaElement; sendButton: HTMLButtonElement; sendIcon: HTMLElement; speechButton: HTMLButtonElement; } { const composer = document.createElement("form"); composer.className = "composer"; const label = createElement("label", "visually-hidden"); label.textContent = "Scrivi una domanda ad Akintu"; const textarea = document.createElement("textarea"); textarea.placeholder = "Ask anything"; textarea.maxLength = 8_000; textarea.rows = kind === "embed" ? 2 : 1; label.htmlFor = "akintu-message"; textarea.id = "akintu-message"; const toolbar = createElement("div", "toolbar"); const speechButton = document.createElement("button"); speechButton.type = "button"; speechButton.className = "speech-button"; speechButton.textContent = "Voce"; speechButton.setAttribute("aria-label", "Detta la domanda"); speechButton.hidden = true; const sendButton = document.createElement("button"); sendButton.type = "submit"; sendButton.className = "send-button"; sendButton.setAttribute("aria-label", "Invia messaggio"); const sendIcon = createElement("span", "send-icon"); sendIcon.setAttribute("aria-hidden", "true"); sendIcon.textContent = "↑"; sendButton.append(sendIcon); toolbar.append(speechButton, sendButton); composer.append(label, textarea, toolbar); return { composer, textarea, sendButton, sendIcon, speechButton }; } function createResumeButton(): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "resume-button"; button.textContent = "Riprendi prenotazione"; button.hidden = true; return button; } function createEmbedDialog( conversation: HTMLElement, status: HTMLElement, error: HTMLElement, ): { dialog: HTMLDialogElement; composerSlot: HTMLElement; closeButton: HTMLButtonElement; resetButton: HTMLButtonElement; } { const dialog = document.createElement("dialog"); dialog.className = "dialog"; dialog.setAttribute("aria-label", "Conversazione con Akintu"); const panel = createElement("section", "dialog-panel"); const header = createElement("header", "dialog-header"); const headerActions = createElement("div", "dialog-header-actions"); const resetButton = document.createElement("button"); resetButton.type = "button"; resetButton.className = "reset-button"; resetButton.textContent = "Nuova conversazione"; const closeButton = document.createElement("button"); closeButton.type = "button"; closeButton.className = "close-button"; closeButton.setAttribute("aria-label", "Chiudi conversazione"); closeButton.textContent = "×"; headerActions.append(resetButton, closeButton); header.append(headerActions); const composerSlot = createElement("div", "dialog-composer-slot"); panel.append(header, conversation, composerSlot, status, error); dialog.append(panel); return { dialog, composerSlot, closeButton, resetButton }; } function createSurface(kind: SurfaceKind): HTMLElement { const surface = createElement("section", `surface surface--${kind}`); surface.setAttribute( "aria-label", kind === "embed" ? "Collega digitale Akintu" : "Approfondisci con Akintu", ); return surface; } function createElement( tagName: K, className: string, ): HTMLElementTagNameMap[K] { const element = document.createElement(tagName); element.className = className; return element; }