From 82ec843c6ca6a17a06e6701e67437fb54bf29aad Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 6 Apr 2026 06:49:59 +0530 Subject: [PATCH] docs: website widget spec + implementation plan - Widget design spec (embeddable AI chat + booking + lead capture) - Implementation plan (6 tasks) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-05-website-widget.md | 979 ++++++++++++++++++ .../specs/2026-04-05-website-widget-design.md | 337 ++++++ 2 files changed, 1316 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-website-widget.md create mode 100644 docs/superpowers/specs/2026-04-05-website-widget-design.md diff --git a/docs/superpowers/plans/2026-04-05-website-widget.md b/docs/superpowers/plans/2026-04-05-website-widget.md new file mode 100644 index 0000000..8c38e7f --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-website-widget.md @@ -0,0 +1,979 @@ +# 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 diff --git a/docs/superpowers/specs/2026-04-05-website-widget-design.md b/docs/superpowers/specs/2026-04-05-website-widget-design.md new file mode 100644 index 0000000..41f4031 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-website-widget-design.md @@ -0,0 +1,337 @@ +# Website Widget — Embeddable AI Chat + Appointment Booking + +**Date**: 2026-04-05 +**Status**: Draft + +--- + +## Overview + +A single JavaScript file that hospitals embed on their website via a ` +``` + +The `data-key` is an HMAC-signed token: `{siteId}.{hmacSignature}`. Cannot be guessed or forged without the server-side secret. + +--- + +## Architecture + +``` +Hospital Website (any tech stack) + └─