# Website Widget — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build an embeddable website widget (AI chat + appointment booking + lead capture) served from the sidecar, with HMAC-signed site keys, captcha protection, and theme integration. **Architecture:** Sidecar gets a new `widget` module with endpoints for init, chat, booking, leads, and key management. A separate Preact-based widget bundle is built with Vite in library mode, served as a static file from the sidecar. The widget renders in a shadow DOM for CSS isolation and fetches theme/config via the site key. **Tech Stack:** NestJS (sidecar endpoints), Preact + Vite (widget bundle), Shadow DOM, HMAC-SHA256 (key signing), reCAPTCHA v3 (captcha) **Spec:** `docs/superpowers/specs/2026-04-05-website-widget-design.md` --- ## File Map ### Sidecar (helix-engage-server) | File | Action | Responsibility | |---|---|---| | `src/widget/widget.module.ts` | Create | NestJS module | | `src/widget/widget.controller.ts` | Create | REST endpoints: init, chat, doctors, slots, book, lead | | `src/widget/widget.service.ts` | Create | Lead creation, appointment booking, doctor queries | | `src/widget/widget-keys.service.ts` | Create | HMAC key generation, validation, CRUD via Redis | | `src/widget/widget-key.guard.ts` | Create | NestJS guard for key + origin validation | | `src/widget/captcha.guard.ts` | Create | reCAPTCHA v3 token verification | | `src/widget/widget.types.ts` | Create | Types for widget requests/responses | | `src/auth/session.service.ts` | Modify | Add `setCachePersistent()` method | | `src/app.module.ts` | Modify | Import WidgetModule | | `src/main.ts` | Modify | Serve static widget.js file | ### Widget Bundle (new package) | File | Action | Responsibility | |---|---|---| | `packages/helix-engage-widget/package.json` | Create | Package config | | `packages/helix-engage-widget/vite.config.ts` | Create | Library mode, IIFE output | | `packages/helix-engage-widget/tsconfig.json` | Create | TypeScript config | | `packages/helix-engage-widget/src/main.ts` | Create | Entry: read data-key, init widget | | `packages/helix-engage-widget/src/api.ts` | Create | HTTP client for widget endpoints | | `packages/helix-engage-widget/src/widget.tsx` | Create | Shadow DOM mount, theming, tab routing | | `packages/helix-engage-widget/src/chat.tsx` | Create | AI chatbot with streaming | | `packages/helix-engage-widget/src/booking.tsx` | Create | Appointment booking flow | | `packages/helix-engage-widget/src/contact.tsx` | Create | Lead capture form | | `packages/helix-engage-widget/src/captcha.ts` | Create | reCAPTCHA v3 integration | | `packages/helix-engage-widget/src/styles.ts` | Create | CSS-in-JS for shadow DOM | | `packages/helix-engage-widget/src/types.ts` | Create | Shared types | --- ### Task 1: Widget Types + Key Service (Sidecar) **Files:** - Create: `helix-engage-server/src/widget/widget.types.ts` - Create: `helix-engage-server/src/widget/widget-keys.service.ts` - Modify: `helix-engage-server/src/auth/session.service.ts` - [ ] **Step 1: Add setCachePersistent to SessionService** Add a method that sets a Redis key without TTL: ```typescript async setCachePersistent(key: string, value: string): Promise { await this.redis.set(key, value); } ``` - [ ] **Step 2: Create widget.types.ts** ```typescript // src/widget/widget.types.ts export type WidgetSiteKey = { siteId: string; hospitalName: string; allowedOrigins: string[]; active: boolean; createdAt: string; }; export type WidgetInitResponse = { brand: { name: string; logo: string }; colors: { primary: string; primaryLight: string; text: string; textLight: string }; captchaSiteKey: string; }; export type WidgetBookRequest = { departmentId: string; doctorId: string; scheduledAt: string; patientName: string; patientPhone: string; age?: string; gender?: string; chiefComplaint?: string; captchaToken: string; }; export type WidgetLeadRequest = { name: string; phone: string; interest?: string; message?: string; captchaToken: string; }; export type WidgetChatRequest = { messages: Array<{ role: string; content: string }>; captchaToken?: string; }; ``` - [ ] **Step 3: Create widget-keys.service.ts** ```typescript // src/widget/widget-keys.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual, randomUUID } from 'crypto'; import { SessionService } from '../auth/session.service'; import type { WidgetSiteKey } from './widget.types'; const KEY_PREFIX = 'widget:keys:'; @Injectable() export class WidgetKeysService { private readonly logger = new Logger(WidgetKeysService.name); private readonly secret: string; constructor( private config: ConfigService, private session: SessionService, ) { this.secret = process.env.WIDGET_SECRET ?? config.get('WIDGET_SECRET') ?? 'helix-widget-default-secret'; } generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } { const siteId = randomUUID().replace(/-/g, '').substring(0, 16); const signature = this.sign(siteId); const key = `${siteId}.${signature}`; const siteKey: WidgetSiteKey = { siteId, hospitalName, allowedOrigins, active: true, createdAt: new Date().toISOString(), }; return { key, siteKey }; } async saveKey(siteKey: WidgetSiteKey): Promise { await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey)); this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`); } async validateKey(rawKey: string): Promise { const dotIndex = rawKey.indexOf('.'); if (dotIndex === -1) return null; const siteId = rawKey.substring(0, dotIndex); const signature = rawKey.substring(dotIndex + 1); // Verify HMAC const expected = this.sign(siteId); try { if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null; } catch { return null; } // Fetch from Redis const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`); if (!data) return null; const siteKey: WidgetSiteKey = JSON.parse(data); if (!siteKey.active) return null; return siteKey; } validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean { if (!origin) return false; if (siteKey.allowedOrigins.length === 0) return true; return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed)); } async listKeys(): Promise { const keys = await this.session.scanKeys(`${KEY_PREFIX}*`); const results: WidgetSiteKey[] = []; for (const key of keys) { const data = await this.session.getCache(key); if (data) results.push(JSON.parse(data)); } return results; } async revokeKey(siteId: string): Promise { const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`); if (!data) return false; const siteKey: WidgetSiteKey = JSON.parse(data); siteKey.active = false; await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey)); this.logger.log(`Widget key revoked: ${siteId}`); return true; } private sign(data: string): string { return createHmac('sha256', this.secret).update(data).digest('hex'); } } ``` - [ ] **Step 4: Commit** ```bash cd helix-engage-server git add src/widget/widget.types.ts src/widget/widget-keys.service.ts src/auth/session.service.ts git commit -m "feat: widget types + HMAC key service with Redis storage" ``` --- ### Task 2: Widget Guards (Key + Captcha) **Files:** - Create: `helix-engage-server/src/widget/widget-key.guard.ts` - Create: `helix-engage-server/src/widget/captcha.guard.ts` - [ ] **Step 1: Create widget-key.guard.ts** ```typescript // src/widget/widget-key.guard.ts import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common'; import { WidgetKeysService } from './widget-keys.service'; @Injectable() export class WidgetKeyGuard implements CanActivate { constructor(private readonly keys: WidgetKeysService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const key = request.query?.key ?? request.headers['x-widget-key']; if (!key) throw new HttpException('Widget key required', 401); const siteKey = await this.keys.validateKey(key); if (!siteKey) throw new HttpException('Invalid widget key', 403); const origin = request.headers.origin ?? request.headers.referer; if (!this.keys.validateOrigin(siteKey, origin)) { throw new HttpException('Origin not allowed', 403); } // Attach to request for downstream use request.widgetSiteKey = siteKey; return true; } } ``` - [ ] **Step 2: Create captcha.guard.ts** ```typescript // src/widget/captcha.guard.ts import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common'; const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; @Injectable() export class CaptchaGuard implements CanActivate { private readonly logger = new Logger(CaptchaGuard.name); private readonly secretKey: string; constructor() { this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? ''; } async canActivate(context: ExecutionContext): Promise { if (!this.secretKey) { this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled'); return true; } const request = context.switchToHttp().getRequest(); const token = request.body?.captchaToken; if (!token) throw new HttpException('Captcha token required', 400); try { const res = await fetch(RECAPTCHA_VERIFY_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `secret=${this.secretKey}&response=${token}`, }); const data = await res.json(); if (!data.success || (data.score != null && data.score < 0.3)) { this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`); throw new HttpException('Captcha verification failed', 403); } return true; } catch (err: any) { if (err instanceof HttpException) throw err; this.logger.error(`Captcha verification error: ${err.message}`); return true; // Fail open if captcha service is down } } } ``` - [ ] **Step 3: Commit** ```bash git add src/widget/widget-key.guard.ts src/widget/captcha.guard.ts git commit -m "feat: widget guards — HMAC key validation + reCAPTCHA v3" ``` --- ### Task 3: Widget Controller + Service + Module (Sidecar) **Files:** - Create: `helix-engage-server/src/widget/widget.service.ts` - Create: `helix-engage-server/src/widget/widget.controller.ts` - Create: `helix-engage-server/src/widget/widget.module.ts` - Modify: `helix-engage-server/src/app.module.ts` - Modify: `helix-engage-server/src/main.ts` - [ ] **Step 1: Create widget.service.ts** ```typescript // src/widget/widget.service.ts import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { ConfigService } from '@nestjs/config'; import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types'; import { ThemeService } from '../config/theme.service'; @Injectable() export class WidgetService { private readonly logger = new Logger(WidgetService.name); private readonly apiKey: string; constructor( private platform: PlatformGraphqlService, private theme: ThemeService, private config: ConfigService, ) { this.apiKey = config.get('platform.apiKey') ?? ''; } getInitData(): WidgetInitResponse { const t = this.theme.getTheme(); return { brand: { name: t.brand.hospitalName, logo: t.brand.logo }, colors: { primary: t.colors.brand['600'] ?? 'rgb(29 78 216)', primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)', text: t.colors.brand['950'] ?? 'rgb(15 23 42)', textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)', }, captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '', }; } async getDoctors(): Promise { const auth = `Bearer ${this.apiKey}`; const data = await this.platform.queryWithAuth( `{ doctors(first: 50) { edges { node { id name fullName { firstName lastName } department specialty visitingHours consultationFeeNew { amountMicros currencyCode } clinic { clinicName } } } } }`, undefined, auth, ); return data.doctors.edges.map((e: any) => e.node); } async getSlots(doctorId: string, date: string): Promise { const auth = `Bearer ${this.apiKey}`; const data = await this.platform.queryWithAuth( `{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`, undefined, auth, ); const booked = data.appointments.edges.map((e: any) => { const dt = new Date(e.node.scheduledAt); return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`; }); const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00']; return allSlots.map(s => ({ time: s, available: !booked.includes(s) })); } async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> { const auth = `Bearer ${this.apiKey}`; const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10); // Create or find patient let patientId: string | null = null; try { const existing = await this.platform.queryWithAuth( `{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`, undefined, auth, ); patientId = existing.patients.edges[0]?.node?.id ?? null; } catch { /* continue */ } if (!patientId) { const created = await this.platform.queryWithAuth( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, { data: { fullName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' }, phones: { primaryPhoneNumber: `+91${phone}` }, patientType: 'NEW', } }, auth, ); patientId = created.createPatient.id; } // Create appointment const appt = await this.platform.queryWithAuth( `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, { data: { scheduledAt: req.scheduledAt, durationMin: 30, appointmentType: 'CONSULTATION', status: 'SCHEDULED', doctorId: req.doctorId, department: req.departmentId, reasonForVisit: req.chiefComplaint ?? '', patientId, } }, auth, ); // Create lead await this.platform.queryWithAuth( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { name: req.patientName, contactName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' }, contactPhone: { primaryPhoneNumber: `+91${phone}` }, source: 'WEBSITE', status: 'APPOINTMENT_SET', interestedService: req.chiefComplaint ?? 'Appointment Booking', patientId, } }, auth, ).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`)); const reference = appt.createAppointment.id.substring(0, 8).toUpperCase(); this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`); return { appointmentId: appt.createAppointment.id, reference }; } async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> { const auth = `Bearer ${this.apiKey}`; const phone = req.phone.replace(/[^0-9]/g, '').slice(-10); const data = await this.platform.queryWithAuth( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { name: req.name, contactName: { firstName: req.name.split(' ')[0], lastName: req.name.split(' ').slice(1).join(' ') || '' }, contactPhone: { primaryPhoneNumber: `+91${phone}` }, source: 'WEBSITE', status: 'NEW', interestedService: req.interest ?? 'Website Enquiry', } }, auth, ); this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`); return { leadId: data.createLead.id }; } } ``` - [ ] **Step 2: Create widget.controller.ts** ```typescript // src/widget/widget.controller.ts import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common'; import type { Request, Response } from 'express'; import { WidgetService } from './widget.service'; import { WidgetKeysService } from './widget-keys.service'; import { WidgetKeyGuard } from './widget-key.guard'; import { CaptchaGuard } from './captcha.guard'; import { AiChatController } from '../ai/ai-chat.controller'; import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types'; @Controller('api/widget') export class WidgetController { private readonly logger = new Logger(WidgetController.name); constructor( private readonly widget: WidgetService, private readonly keys: WidgetKeysService, ) {} @Get('init') @UseGuards(WidgetKeyGuard) init() { return this.widget.getInitData(); } @Get('doctors') @UseGuards(WidgetKeyGuard) async doctors() { return this.widget.getDoctors(); } @Get('slots') @UseGuards(WidgetKeyGuard) async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) { if (!doctorId || !date) throw new HttpException('doctorId and date required', 400); return this.widget.getSlots(doctorId, date); } @Post('book') @UseGuards(WidgetKeyGuard, CaptchaGuard) async book(@Body() body: WidgetBookRequest) { if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) { throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400); } return this.widget.bookAppointment(body); } @Post('lead') @UseGuards(WidgetKeyGuard, CaptchaGuard) async lead(@Body() body: WidgetLeadRequest) { if (!body.name || !body.phone) { throw new HttpException('name and phone required', 400); } return this.widget.createLead(body); } // Key management (admin only — no widget key guard, requires JWT) @Post('keys/generate') async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) { if (!body.hospitalName) throw new HttpException('hospitalName required', 400); const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []); await this.keys.saveKey(siteKey); return { key, siteKey }; } @Get('keys') async listKeys() { return this.keys.listKeys(); } @Delete('keys/:siteId') async revokeKey(@Param('siteId') siteId: string) { const revoked = await this.keys.revokeKey(siteId); if (!revoked) throw new HttpException('Key not found', 404); return { status: 'revoked' }; } } ``` - [ ] **Step 3: Create widget.module.ts** ```typescript // src/widget/widget.module.ts import { Module } from '@nestjs/common'; import { WidgetController } from './widget.controller'; import { WidgetService } from './widget.service'; import { WidgetKeysService } from './widget-keys.service'; import { PlatformModule } from '../platform/platform.module'; import { AuthModule } from '../auth/auth.module'; import { ConfigThemeModule } from '../config/config-theme.module'; @Module({ imports: [PlatformModule, AuthModule, ConfigThemeModule], controllers: [WidgetController], providers: [WidgetService, WidgetKeysService], exports: [WidgetKeysService], }) export class WidgetModule {} ``` - [ ] **Step 4: Register in app.module.ts** Add import: ```typescript import { WidgetModule } from './widget/widget.module'; ``` Add to imports array: ```typescript WidgetModule, ``` - [ ] **Step 5: Serve static widget.js from main.ts** In `src/main.ts`, after the NestJS app bootstrap, add static file serving for the widget bundle: ```typescript import { join } from 'path'; import { NestExpressApplication } from '@nestjs/platform-express'; // After app.listen(): app.useStaticAssets(join(__dirname, '..', 'public'), { prefix: '/' }); ``` Create `helix-engage-server/public/` directory for the widget bundle output. - [ ] **Step 6: Build and verify** ```bash cd helix-engage-server && npm run build ``` - [ ] **Step 7: Commit** ```bash git add src/widget/ src/app.module.ts src/main.ts public/ git commit -m "feat: widget module — endpoints, service, key management, captcha" ``` --- ### Task 4: Widget Bundle — Project Setup + Entry Point **Files:** - Create: `packages/helix-engage-widget/package.json` - Create: `packages/helix-engage-widget/vite.config.ts` - Create: `packages/helix-engage-widget/tsconfig.json` - Create: `packages/helix-engage-widget/src/types.ts` - Create: `packages/helix-engage-widget/src/api.ts` - Create: `packages/helix-engage-widget/src/main.ts` - [ ] **Step 1: Create package.json** ```json { "name": "helix-engage-widget", "version": "0.1.0", "private": true, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "preact": "^10.25.0" }, "devDependencies": { "@preact/preset-vite": "^2.9.0", "typescript": "^5.7.0", "vite": "^7.0.0" } } ``` - [ ] **Step 2: Create vite.config.ts** ```typescript import { defineConfig } from 'vite'; import preact from '@preact/preset-vite'; export default defineConfig({ plugins: [preact()], build: { lib: { entry: 'src/main.ts', name: 'HelixWidget', fileName: () => 'widget.js', formats: ['iife'], }, outDir: '../../helix-engage-server/public', emptyOutDir: false, minify: 'terser', rollupOptions: { output: { inlineDynamicImports: true, }, }, }, }); ``` - [ ] **Step 3: Create tsconfig.json** ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "jsxImportSource": "preact", "strict": true, "esModuleInterop": true, "outDir": "dist", "skipLibCheck": true }, "include": ["src"] } ``` - [ ] **Step 4: Create types.ts** ```typescript // src/types.ts export type WidgetConfig = { brand: { name: string; logo: string }; colors: { primary: string; primaryLight: string; text: string; textLight: string }; captchaSiteKey: string; }; export type Doctor = { id: string; name: string; fullName: { firstName: string; lastName: string }; department: string; specialty: string; visitingHours: string; consultationFeeNew: { amountMicros: number; currencyCode: string } | null; clinic: { clinicName: string } | null; }; export type TimeSlot = { time: string; available: boolean; }; export type ChatMessage = { role: 'user' | 'assistant'; content: string; }; ``` - [ ] **Step 5: Create api.ts** ```typescript // src/api.ts import type { WidgetConfig, Doctor, TimeSlot } from './types'; let baseUrl = ''; let widgetKey = ''; export const initApi = (url: string, key: string) => { baseUrl = url; widgetKey = key; }; const headers = () => ({ 'Content-Type': 'application/json', 'X-Widget-Key': widgetKey, }); export const fetchInit = async (): Promise => { const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`); if (!res.ok) throw new Error('Widget init failed'); return res.json(); }; export const fetchDoctors = async (): Promise => { const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`); if (!res.ok) throw new Error('Failed to load doctors'); return res.json(); }; export const fetchSlots = async (doctorId: string, date: string): Promise => { const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`); if (!res.ok) throw new Error('Failed to load slots'); return res.json(); }; export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => { const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, { method: 'POST', headers: headers(), body: JSON.stringify(data), }); if (!res.ok) throw new Error('Booking failed'); return res.json(); }; export const submitLead = async (data: any): Promise<{ leadId: string }> => { const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, { method: 'POST', headers: headers(), body: JSON.stringify(data), }); if (!res.ok) throw new Error('Submission failed'); return res.json(); }; export const streamChat = async (messages: any[], captchaToken?: string): Promise => { const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, { method: 'POST', headers: headers(), body: JSON.stringify({ messages, captchaToken }), }); if (!res.ok || !res.body) throw new Error('Chat failed'); return res.body; }; ``` - [ ] **Step 6: Create main.ts** ```typescript // src/main.ts import { render } from 'preact'; import { initApi, fetchInit } from './api'; import { Widget } from './widget'; import type { WidgetConfig } from './types'; const init = async () => { const script = document.querySelector('script[data-key]') as HTMLScriptElement | null; if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; } const key = script.getAttribute('data-key') ?? ''; const baseUrl = script.src.replace(/\/widget\.js.*$/, ''); initApi(baseUrl, key); let config: WidgetConfig; try { config = await fetchInit(); } catch (err) { console.error('[HelixWidget] Init failed:', err); return; } // Create shadow DOM host const host = document.createElement('div'); host.id = 'helix-widget-host'; host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;'; document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); const mountPoint = document.createElement('div'); shadow.appendChild(mountPoint); render(, mountPoint); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } ``` - [ ] **Step 7: Install dependencies and verify** ```bash cd packages/helix-engage-widget && npm install ``` - [ ] **Step 8: Commit** ```bash git add packages/helix-engage-widget/ git commit -m "feat: widget bundle — project setup, API client, entry point" ``` --- ### Task 5: Widget UI Components (Preact) **Files:** - Create: `packages/helix-engage-widget/src/styles.ts` - Create: `packages/helix-engage-widget/src/widget.tsx` - Create: `packages/helix-engage-widget/src/chat.tsx` - Create: `packages/helix-engage-widget/src/booking.tsx` - Create: `packages/helix-engage-widget/src/contact.tsx` - Create: `packages/helix-engage-widget/src/captcha.ts` These are the Preact components rendered inside the shadow DOM. Each component is self-contained. - [ ] **Step 1: Create styles.ts** — CSS string injected into shadow DOM - [ ] **Step 2: Create widget.tsx** — Main shell with bubble, panel, tab routing - [ ] **Step 3: Create chat.tsx** — AI chat with streaming, quick actions, lead capture fallback - [ ] **Step 4: Create booking.tsx** — Step-by-step appointment booking - [ ] **Step 5: Create contact.tsx** — Simple lead capture form - [ ] **Step 6: Create captcha.ts** — Load reCAPTCHA script, get token Each component follows the pattern: fetch data from API, render form/chat, submit with captcha token. - [ ] **Step 7: Build the widget** ```bash cd packages/helix-engage-widget && npm run build # Output: ../../helix-engage-server/public/widget.js ``` - [ ] **Step 8: Commit** ```bash git add packages/helix-engage-widget/src/ git commit -m "feat: widget UI — chat, booking, contact, theming, shadow DOM" ``` --- ### Task 6: Integration Test + Key Generation **Files:** - None new — testing the full flow - [ ] **Step 1: Generate a site key** ```bash curl -s -X POST http://localhost:4100/api/widget/keys/generate \ -H "Content-Type: application/json" \ -d '{"hospitalName":"Global Hospital","allowedOrigins":["http://localhost:3000","http://localhost:5173"]}' | python3 -m json.tool ``` Save the returned `key` value. - [ ] **Step 2: Test init endpoint** ```bash curl -s "http://localhost:4100/api/widget/init?key=SITE_KEY_HERE" | python3 -m json.tool ``` Should return theme config with brand name, colors, captcha site key. - [ ] **Step 3: Test widget.js serving** ```bash curl -s -o /dev/null -w "%{http_code}" http://localhost:4100/widget.js ``` Should return 200. - [ ] **Step 4: Create a test HTML page** Create `packages/helix-engage-widget/test.html`: ```html Widget Test

Hospital Website

This is a test page for the Helix Engage widget.

``` Open in browser, verify the floating bubble appears, themed correctly. - [ ] **Step 5: Test booking flow end-to-end** Click Book tab → select department → doctor → date → slot → fill name + phone → submit. Verify appointment + lead created in platform. - [ ] **Step 6: Build sidecar and commit all** ```bash cd helix-engage-server && npm run build git add -A && git commit -m "feat: website widget — full integration (chat + booking + lead capture)" ``` --- ## Execution Notes - The widget bundle builds into `helix-engage-server/public/widget.js` — Vite outputs directly to the sidecar's public dir - The sidecar serves it via Express static middleware - Site keys use HMAC-SHA256 with `WIDGET_SECRET` env var - Captcha is gated by `RECAPTCHA_SECRET_KEY` env var — if not set, captcha is disabled (dev mode) - All widget endpoints use the server-side API key for platform queries (not the visitor's JWT) - The widget has no dependency on the main helix-engage frontend — completely standalone - Task 5 steps are intentionally less detailed — the UI components follow standard Preact patterns and depend on the API client from Task 4