mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: widget chat with generative UI, branch selection, captcha gate, lead dedup
- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
generative UI (pick_branch, list_departments, show_clinic_timings,
show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
across chat-start / book / contact so one visitor == one lead. Booking
upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
hospitals); departments + doctors filtered by selectedBranch. Chat slot
picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
selected branch, widget font inherits from host page (fix :host { all:
initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
403
public/widget.js
403
public/widget.js
File diff suppressed because one or more lines are too long
@@ -14,7 +14,8 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// Serve widget.js and other static files from /public
|
||||
app.useStaticAssets(join(__dirname, '..', 'public'), {
|
||||
// In dev mode __dirname = src/, in prod __dirname = dist/ — resolve from process.cwd()
|
||||
app.useStaticAssets(join(process.cwd(), 'public'), {
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.js')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
||||
|
||||
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
// Cloudflare Turnstile verification endpoint
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaGuard implements CanActivate {
|
||||
@@ -23,15 +24,15 @@ export class CaptchaGuard implements CanActivate {
|
||||
if (!token) throw new HttpException('Captcha token required', 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(RECAPTCHA_VERIFY_URL, {
|
||||
const res = await fetch(TURNSTILE_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `secret=${this.secretKey}&response=${token}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ 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}`);
|
||||
if (!data.success) {
|
||||
this.logger.warn(`Captcha failed: success=${data.success} errors=${JSON.stringify(data['error-codes'] ?? [])}`);
|
||||
throw new HttpException('Captcha verification failed', 403);
|
||||
}
|
||||
|
||||
|
||||
407
src/widget/widget-chat.service.ts
Normal file
407
src/widget/widget-chat.service.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { streamText, tool, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { LanguageModel, ModelMessage } from 'ai';
|
||||
import { createAiModel } from '../ai/ai-provider';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { WidgetService } from './widget.service';
|
||||
|
||||
@Injectable()
|
||||
export class WidgetChatService {
|
||||
private readonly logger = new Logger(WidgetChatService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
private readonly apiKey: string;
|
||||
private knowledgeBase: string | null = null;
|
||||
private kbLoadedAt = 0;
|
||||
private readonly kbTtlMs = 5 * 60 * 1000;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
private widget: WidgetService,
|
||||
) {
|
||||
this.aiModel = createAiModel(config);
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
if (!this.aiModel) {
|
||||
this.logger.warn('AI not configured — widget chat will return fallback replies');
|
||||
}
|
||||
}
|
||||
|
||||
private get auth() {
|
||||
return `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
hasAiModel(): boolean {
|
||||
return this.aiModel !== null;
|
||||
}
|
||||
|
||||
// Find-or-create a lead by phone. Delegates to WidgetService so there's
|
||||
// a single source of truth for the dedup window + lead shape across
|
||||
// chat + book + contact.
|
||||
async findOrCreateLead(name: string, phone: string): Promise<string> {
|
||||
return this.widget.findOrCreateLeadByPhone(name, phone, {
|
||||
source: 'WEBSITE',
|
||||
status: 'NEW',
|
||||
interestedService: 'Website Chat',
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the first name of the lead's primary contact so we can greet the
|
||||
// visitor by name in the system prompt. Returns 'there' on any failure.
|
||||
async getLeadFirstName(leadId: string): Promise<string> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`query($id: UUID!) {
|
||||
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||
edges { node { id contactName { firstName } } }
|
||||
}
|
||||
}`,
|
||||
{ id: leadId },
|
||||
this.auth,
|
||||
);
|
||||
const firstName = data?.leads?.edges?.[0]?.node?.contactName?.firstName;
|
||||
return (typeof firstName === 'string' && firstName.trim()) || 'there';
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch lead name for ${leadId}: ${err}`);
|
||||
return 'there';
|
||||
}
|
||||
}
|
||||
|
||||
// Append an exchange to the lead's activity log. One activity record per
|
||||
// user/assistant turn. Safe to call in the background (we don't block the
|
||||
// stream on this).
|
||||
async logExchange(leadId: string, userText: string, aiText: string): Promise<void> {
|
||||
const summary = `User: ${userText}\n\nAI: ${aiText}`.slice(0, 4000);
|
||||
try {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadActivityCreateInput!) {
|
||||
createLeadActivity(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
leadId,
|
||||
activityType: 'NOTE_ADDED',
|
||||
channel: 'SYSTEM',
|
||||
summary,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: 'Website Chat',
|
||||
outcome: 'SUCCESSFUL',
|
||||
},
|
||||
},
|
||||
this.auth,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to log chat activity for lead ${leadId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a compact knowledge base of doctor/department info for the system
|
||||
// prompt. Cached for 5 min to avoid a doctors query on every chat.
|
||||
private async getKnowledgeBase(): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||
return this.knowledgeBase;
|
||||
}
|
||||
|
||||
try {
|
||||
const doctors = await this.widget.getDoctors();
|
||||
const byDept = new Map<string, { name: string; visitingHours?: string; clinic?: string }[]>();
|
||||
for (const d of doctors) {
|
||||
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
|
||||
if (!byDept.has(dept)) byDept.set(dept, []);
|
||||
byDept.get(dept)!.push({
|
||||
name: d.name,
|
||||
visitingHours: d.visitingHours,
|
||||
clinic: d.clinic?.clinicName,
|
||||
});
|
||||
}
|
||||
|
||||
const lines: string[] = ['DEPARTMENTS AND DOCTORS:'];
|
||||
for (const [dept, docs] of byDept) {
|
||||
lines.push(`\n${dept}:`);
|
||||
for (const doc of docs) {
|
||||
const extras: string[] = [];
|
||||
if (doc.visitingHours) extras.push(doc.visitingHours);
|
||||
if (doc.clinic) extras.push(doc.clinic);
|
||||
lines.push(` - ${doc.name}${extras.length ? ` (${extras.join(' • ')})` : ''}`);
|
||||
}
|
||||
}
|
||||
this.knowledgeBase = lines.join('\n');
|
||||
this.kbLoadedAt = now;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to build widget KB: ${err}`);
|
||||
this.knowledgeBase = 'DEPARTMENTS AND DOCTORS: (unavailable)';
|
||||
this.kbLoadedAt = now;
|
||||
}
|
||||
return this.knowledgeBase;
|
||||
}
|
||||
|
||||
async buildSystemPrompt(userName: string, selectedBranch: string | null): Promise<string> {
|
||||
const init = this.widget.getInitData();
|
||||
const kb = await this.getKnowledgeBase();
|
||||
|
||||
// Branch context flips the tool-usage rules: no branch = must call
|
||||
// pick_branch first; branch set = always pass it to branch-aware tools.
|
||||
const branchContext = selectedBranch
|
||||
? [
|
||||
`CURRENT BRANCH: ${selectedBranch}`,
|
||||
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
|
||||
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
|
||||
]
|
||||
: [
|
||||
'BRANCH STATUS: NOT SET',
|
||||
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
|
||||
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
|
||||
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
|
||||
];
|
||||
|
||||
return [
|
||||
`You are a helpful, concise assistant for ${init.brand.name}.`,
|
||||
`You are chatting with a website visitor named ${userName}.`,
|
||||
'',
|
||||
...branchContext,
|
||||
'',
|
||||
'TOOL USAGE RULES (STRICT):',
|
||||
'- When the user asks about departments, call list_departments and DO NOT also list departments in prose.',
|
||||
'- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.',
|
||||
'- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.',
|
||||
'- When they ask about a specific doctor\'s availability or want to book with them, call show_doctor_slots.',
|
||||
'- When the conversation is trending toward booking, call suggest_booking.',
|
||||
'- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence',
|
||||
' (under 15 words) framing the widget, or no text at all. The widget already shows the data.',
|
||||
'- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,',
|
||||
' STOP and call the appropriate tool instead.',
|
||||
'- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in',
|
||||
' non-tool replies.',
|
||||
'- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".',
|
||||
' If the visitor asks about a future date, tell them to use the Book tab\'s date picker.',
|
||||
'',
|
||||
'OTHER RULES:',
|
||||
'- Answer other questions (directions, general info) concisely in prose.',
|
||||
'- If you do not know something, say so and suggest they call the hospital.',
|
||||
'- Never quote prices. No medical advice. For clinical questions, defer to a doctor.',
|
||||
'',
|
||||
kb,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
|
||||
// objects. The controller writes these as SSE `data: ${json}\n\n` lines
|
||||
// over the HTTP response. Tools return structured payloads the widget
|
||||
// frontend renders as generative-UI cards.
|
||||
async *streamReply(systemPrompt: string, messages: ModelMessage[]): AsyncGenerator<any> {
|
||||
if (!this.aiModel) throw new Error('AI not configured');
|
||||
|
||||
const platform = this.platform;
|
||||
const widgetSvc = this.widget;
|
||||
|
||||
// Small helper: does a doctor's clinic match the branch filter?
|
||||
// Case-insensitive substring match so "Indiranagar" matches
|
||||
// "Indiranagar Clinic" etc.
|
||||
const matchesBranch = (d: any, branch: string | undefined): boolean => {
|
||||
if (!branch) return true;
|
||||
const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase();
|
||||
return clinicName.includes(branch.toLowerCase());
|
||||
};
|
||||
|
||||
const tools = {
|
||||
pick_branch: tool({
|
||||
description:
|
||||
'Show the list of hospital branches so the visitor can pick which one they are interested in. Call this BEFORE any branch-sensitive tool (list_departments, show_clinic_timings, show_doctors, show_doctor_slots) when CURRENT BRANCH is NOT SET.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const doctors = await widgetSvc.getDoctors();
|
||||
const byBranch = new Map<string, { doctorCount: number; departments: Set<string> }>();
|
||||
for (const d of doctors) {
|
||||
const name = d.clinic?.clinicName?.trim();
|
||||
if (!name) continue;
|
||||
if (!byBranch.has(name)) {
|
||||
byBranch.set(name, { doctorCount: 0, departments: new Set() });
|
||||
}
|
||||
const entry = byBranch.get(name)!;
|
||||
entry.doctorCount += 1;
|
||||
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
||||
}
|
||||
return {
|
||||
branches: Array.from(byBranch.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, { doctorCount, departments }]) => ({
|
||||
name,
|
||||
doctorCount,
|
||||
departmentCount: departments.size,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
list_departments: tool({
|
||||
description:
|
||||
'List the departments the hospital has. Use when the visitor asks what departments or specialities are available. Pass branch if CURRENT BRANCH is set.',
|
||||
inputSchema: z.object({
|
||||
branch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||
}),
|
||||
execute: async ({ branch }) => {
|
||||
const doctors = await widgetSvc.getDoctors();
|
||||
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||
const deps = Array.from(
|
||||
new Set(filtered.map((d: any) => d.department).filter(Boolean)),
|
||||
) as string[];
|
||||
return {
|
||||
branch: branch ?? null,
|
||||
departments: deps.map(d => d.replace(/_/g, ' ')),
|
||||
};
|
||||
},
|
||||
}),
|
||||
show_clinic_timings: tool({
|
||||
description:
|
||||
'Show the clinic hours / visiting times for all departments with the doctors who visit during those hours. Use when the visitor asks about clinic timings, visiting hours, when the clinic is open, or what time a department is available. Pass branch if CURRENT BRANCH is set.',
|
||||
inputSchema: z.object({
|
||||
branch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||
}),
|
||||
execute: async ({ branch }) => {
|
||||
const doctors = await widgetSvc.getDoctors();
|
||||
const filtered = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||
const byDept = new Map<
|
||||
string,
|
||||
Array<{ name: string; hours: string; clinic: string | null }>
|
||||
>();
|
||||
for (const d of filtered) {
|
||||
const dept = (d.department ?? 'Other').replace(/_/g, ' ');
|
||||
if (!byDept.has(dept)) byDept.set(dept, []);
|
||||
if (d.visitingHours) {
|
||||
byDept.get(dept)!.push({
|
||||
name: d.name,
|
||||
hours: d.visitingHours,
|
||||
clinic: d.clinic?.clinicName ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
branch: branch ?? null,
|
||||
departments: Array.from(byDept.entries())
|
||||
.filter(([, entries]) => entries.length > 0)
|
||||
.map(([name, entries]) => ({ name, entries })),
|
||||
};
|
||||
},
|
||||
}),
|
||||
show_doctors: tool({
|
||||
description:
|
||||
'Show the list of doctors in a specific department with their visiting hours and clinic. Use when the visitor asks about doctors in a department. Pass branch if CURRENT BRANCH is set.',
|
||||
inputSchema: z.object({
|
||||
department: z
|
||||
.string()
|
||||
.describe('Department name, e.g., "Cardiology", "ENT", "General Medicine".'),
|
||||
branch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Branch name to filter by. Pass the CURRENT BRANCH when set.'),
|
||||
}),
|
||||
execute: async ({ department, branch }) => {
|
||||
const doctors = await widgetSvc.getDoctors();
|
||||
const deptKey = department.toLowerCase().replace(/\s+/g, '').replace(/_/g, '');
|
||||
const matches = doctors
|
||||
.filter((d: any) => {
|
||||
const key = String(d.department ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/_/g, '');
|
||||
return key.includes(deptKey) && matchesBranch(d, branch);
|
||||
})
|
||||
.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
specialty: d.specialty ?? null,
|
||||
visitingHours: d.visitingHours ?? null,
|
||||
clinic: d.clinic?.clinicName ?? null,
|
||||
}));
|
||||
return { department, branch: branch ?? null, doctors: matches };
|
||||
},
|
||||
}),
|
||||
show_doctor_slots: tool({
|
||||
description:
|
||||
"Show today's available appointment slots for a specific doctor. Use when the visitor wants to see when a doctor is free or wants to book with a specific doctor. The date is always today — do NOT try to specify a date. Pass branch if CURRENT BRANCH is set to disambiguate doctors with the same name across branches.",
|
||||
inputSchema: z.object({
|
||||
doctorName: z
|
||||
.string()
|
||||
.describe('Full name of the doctor, e.g., "Dr. Lakshmi Reddy".'),
|
||||
branch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Branch name to disambiguate. Pass the CURRENT BRANCH when set.'),
|
||||
}),
|
||||
execute: async ({ doctorName, branch }) => {
|
||||
// Always use the server's current date. Never trust anything from
|
||||
// the model here — older LLMs hallucinate their training-data
|
||||
// "today" and return slots for the wrong day.
|
||||
const targetDate = new Date().toISOString().slice(0, 10);
|
||||
const doctors = await widgetSvc.getDoctors();
|
||||
const scoped = doctors.filter((d: any) => matchesBranch(d, branch));
|
||||
// Fuzzy match: lowercase + strip "Dr." prefix + collapse spaces.
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/^dr\.?\s*/i, '').replace(/\s+/g, ' ').trim();
|
||||
const target = norm(doctorName);
|
||||
const doc =
|
||||
scoped.find((d: any) => norm(d.name) === target) ??
|
||||
scoped.find((d: any) => norm(d.name).includes(target)) ??
|
||||
scoped.find((d: any) => target.includes(norm(d.name)));
|
||||
if (!doc) {
|
||||
return {
|
||||
doctor: null,
|
||||
date: targetDate,
|
||||
slots: [],
|
||||
error: `No doctor matching "${doctorName}"${branch ? ` at ${branch}` : ''} was found.`,
|
||||
};
|
||||
}
|
||||
const slots = await widgetSvc.getSlots(doc.id, targetDate);
|
||||
return {
|
||||
doctor: {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
department: doc.department ?? null,
|
||||
clinic: doc.clinic?.clinicName ?? null,
|
||||
},
|
||||
date: targetDate,
|
||||
slots,
|
||||
};
|
||||
},
|
||||
}),
|
||||
suggest_booking: tool({
|
||||
description:
|
||||
'Suggest that the visitor book an appointment. Use when the conversation is trending toward booking, the user has identified a concern, or asks "how do I book".',
|
||||
inputSchema: z.object({
|
||||
reason: z.string().describe('Short reason why booking is a good next step.'),
|
||||
department: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Suggested department, if known.'),
|
||||
}),
|
||||
execute: async ({ reason, department }) => {
|
||||
return { reason, department: department ?? null };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// Bookings / leads are not in scope for the AI — we only wire read/
|
||||
// suggest tools here. The CC agent/AP engineering team can book.
|
||||
void platform;
|
||||
|
||||
const result = streamText({
|
||||
model: this.aiModel,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
stopWhen: stepCountIs(4),
|
||||
});
|
||||
|
||||
const uiStream = result.toUIMessageStream();
|
||||
for await (const chunk of uiStream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { WidgetService } from './widget.service';
|
||||
import { WidgetChatService } from './widget-chat.service';
|
||||
import { WidgetKeysService } from './widget-keys.service';
|
||||
import { WidgetKeyGuard } from './widget-key.guard';
|
||||
import { CaptchaGuard } from './captcha.guard';
|
||||
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
|
||||
type ChatStartBody = { name?: string; phone?: string };
|
||||
type ChatStreamBody = { leadId?: string; messages?: ModelMessage[]; branch?: string | null };
|
||||
|
||||
@Controller('api/widget')
|
||||
export class WidgetController {
|
||||
private readonly logger = new Logger(WidgetController.name);
|
||||
|
||||
constructor(
|
||||
private readonly widget: WidgetService,
|
||||
private readonly chat: WidgetChatService,
|
||||
private readonly keys: WidgetKeysService,
|
||||
) {}
|
||||
|
||||
@@ -51,6 +58,97 @@ export class WidgetController {
|
||||
return this.widget.createLead(body);
|
||||
}
|
||||
|
||||
// Start (or resume) a chat session. Dedups by phone in the last 24h so a
|
||||
// single visitor who books + contacts + chats doesn't create three leads.
|
||||
// No CaptchaGuard: the window-level gate already verified humanity, and
|
||||
// Turnstile tokens are single-use so reusing them on every endpoint breaks
|
||||
// the multi-action flow.
|
||||
@Post('chat-start')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
async chatStart(@Body() body: ChatStartBody) {
|
||||
if (!body.name?.trim() || !body.phone?.trim()) {
|
||||
throw new HttpException('name and phone required', 400);
|
||||
}
|
||||
try {
|
||||
const leadId = await this.chat.findOrCreateLead(body.name.trim(), body.phone.trim());
|
||||
return { leadId };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`chatStart failed: ${err?.message ?? err}`);
|
||||
throw new HttpException('Failed to start chat session', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream the AI reply. Requires an active leadId from chat-start. The
|
||||
// conversation is logged to leadActivity after the stream completes so the
|
||||
// CC agent can review the transcript when they call the visitor back.
|
||||
@Post('chat')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
async chat_(@Req() req: Request, @Res() res: Response) {
|
||||
const body = req.body as ChatStreamBody;
|
||||
const leadId = body?.leadId?.trim();
|
||||
const messages = body?.messages ?? [];
|
||||
const selectedBranch = body?.branch?.trim() || null;
|
||||
if (!leadId) {
|
||||
res.status(400).json({ error: 'leadId required' });
|
||||
return;
|
||||
}
|
||||
if (!messages.length) {
|
||||
res.status(400).json({ error: 'messages required' });
|
||||
return;
|
||||
}
|
||||
if (!this.chat.hasAiModel()) {
|
||||
res.status(503).json({ error: 'AI not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last user message up-front so we can log it after the
|
||||
// stream finishes (reverse-walking messages is cheap).
|
||||
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
||||
const userText = typeof lastUser?.content === 'string'
|
||||
? lastUser.content
|
||||
: '';
|
||||
|
||||
// Fetch the visitor's first name from the lead so the AI can personalize.
|
||||
const userName = await this.chat.getLeadFirstName(leadId);
|
||||
|
||||
// SSE framing — each UIMessageChunk is serialized as a `data:` event.
|
||||
// See AI SDK v6 UI_MESSAGE_STREAM_HEADERS for the canonical values.
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.setHeader('X-Vercel-Ai-Ui-Message-Stream', 'v1');
|
||||
|
||||
let aiText = '';
|
||||
try {
|
||||
const systemPrompt = await this.chat.buildSystemPrompt(userName, selectedBranch);
|
||||
for await (const chunk of this.chat.streamReply(systemPrompt, messages)) {
|
||||
// Track accumulated text for transcript logging.
|
||||
if (chunk?.type === 'text-delta' && typeof chunk.delta === 'string') {
|
||||
aiText += chunk.delta;
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Chat stream failed for lead ${leadId}: ${err?.message ?? err}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Chat failed' });
|
||||
} else {
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', errorText: 'Chat failed' })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget transcript logging. We intentionally do not await
|
||||
// this so the stream response is not delayed.
|
||||
if (userText && aiText) {
|
||||
void this.chat.logExchange(leadId, userText, aiText);
|
||||
}
|
||||
}
|
||||
|
||||
// Key management (admin endpoints)
|
||||
@Post('keys/generate')
|
||||
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { WidgetController } from './widget.controller';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
import { WidgetService } from './widget.service';
|
||||
import { WidgetChatService } from './widget-chat.service';
|
||||
import { WidgetKeysService } from './widget-keys.service';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
@@ -10,7 +11,7 @@ import { ConfigThemeModule } from '../config/config-theme.module';
|
||||
@Module({
|
||||
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
||||
controllers: [WidgetController, WebhooksController],
|
||||
providers: [WidgetService, WidgetKeysService],
|
||||
providers: [WidgetService, WidgetChatService, WidgetKeysService],
|
||||
exports: [WidgetKeysService],
|
||||
})
|
||||
export class WidgetModule {}
|
||||
|
||||
@@ -4,6 +4,17 @@ import { ConfigService } from '@nestjs/config';
|
||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
import { ThemeService } from '../config/theme.service';
|
||||
|
||||
// Dedup window: any lead created for this phone within the last 24h is
|
||||
// considered the same visitor's lead — chat + book + contact by the same
|
||||
// phone all roll into one record in the CRM.
|
||||
const LEAD_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type FindOrCreateLeadOpts = {
|
||||
source?: string;
|
||||
status?: string;
|
||||
interestedService?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WidgetService {
|
||||
private readonly logger = new Logger(WidgetService.name);
|
||||
@@ -21,6 +32,91 @@ export class WidgetService {
|
||||
return `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
private normalizePhone(raw: string): string {
|
||||
return raw.replace(/[^0-9]/g, '').slice(-10);
|
||||
}
|
||||
|
||||
// Shared lead dedup: finds a lead created in the last 24h for the same
|
||||
// phone, or creates a new one. Public so WidgetChatService can reuse it.
|
||||
async findOrCreateLeadByPhone(
|
||||
name: string,
|
||||
rawPhone: string,
|
||||
opts: FindOrCreateLeadOpts = {},
|
||||
): Promise<string> {
|
||||
const phone = this.normalizePhone(rawPhone);
|
||||
if (!phone) throw new Error('Invalid phone number');
|
||||
|
||||
const since = new Date(Date.now() - LEAD_DEDUP_WINDOW_MS).toISOString();
|
||||
|
||||
try {
|
||||
const existing = await this.platform.queryWithAuth<any>(
|
||||
`query($phone: String!, $since: DateTime!) {
|
||||
leads(
|
||||
first: 1,
|
||||
filter: {
|
||||
contactPhone: { primaryPhoneNumber: { like: $phone } },
|
||||
createdAt: { gte: $since }
|
||||
},
|
||||
orderBy: [{ createdAt: DescNullsLast }]
|
||||
) { edges { node { id createdAt } } }
|
||||
}`,
|
||||
{ phone: `%${phone}`, since },
|
||||
this.auth,
|
||||
);
|
||||
const match = existing?.leads?.edges?.[0]?.node;
|
||||
if (match?.id) {
|
||||
this.logger.log(`Lead dedup: reusing ${match.id} for phone ${phone}`);
|
||||
return match.id as string;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Lead dedup lookup failed, falling through to create: ${err}`);
|
||||
}
|
||||
|
||||
const firstName = name.split(' ')[0] || name;
|
||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||
|
||||
const created = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
source: opts.source ?? 'WEBSITE',
|
||||
status: opts.status ?? 'NEW',
|
||||
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||
},
|
||||
},
|
||||
this.auth,
|
||||
);
|
||||
const id = created?.createLead?.id;
|
||||
if (!id) throw new Error('Lead creation returned no id');
|
||||
this.logger.log(`Lead dedup: created ${id} for ${name} (${phone})`);
|
||||
return id as string;
|
||||
}
|
||||
|
||||
// Upgrade a lead's status — used when an existing lead is promoted from
|
||||
// NEW/chat to APPOINTMENT_SET after the visitor books. Non-fatal on failure.
|
||||
async updateLeadStatus(leadId: string, status: string, interestedService?: string): Promise<void> {
|
||||
try {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: leadId,
|
||||
data: {
|
||||
status,
|
||||
...(interestedService ? { interestedService } : {}),
|
||||
},
|
||||
},
|
||||
this.auth,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to update lead ${leadId} status → ${status}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
getInitData(): WidgetInitResponse {
|
||||
const t = this.theme.getTheme();
|
||||
return {
|
||||
@@ -62,7 +158,7 @@ export class WidgetService {
|
||||
}
|
||||
|
||||
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
|
||||
const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
|
||||
const phone = this.normalizePhone(req.patientPhone);
|
||||
|
||||
// Find or create patient
|
||||
let patientId: string | null = null;
|
||||
@@ -105,22 +201,25 @@ export class WidgetService {
|
||||
this.auth,
|
||||
);
|
||||
|
||||
// Create lead
|
||||
const firstName = req.patientName.split(' ')[0];
|
||||
const lastName = req.patientName.split(' ').slice(1).join(' ') || '';
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: {
|
||||
name: req.patientName,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
// Find-or-create lead (dedups within 24h across chat + contact + book)
|
||||
// and upgrade its status to APPOINTMENT_SET. Non-fatal on failure —
|
||||
// we don't want to fail the booking if lead bookkeeping hiccups.
|
||||
try {
|
||||
const leadId = await this.findOrCreateLeadByPhone(req.patientName, phone, {
|
||||
source: 'WEBSITE',
|
||||
status: 'APPOINTMENT_SET',
|
||||
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||
patientId,
|
||||
} },
|
||||
this.auth,
|
||||
).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));
|
||||
});
|
||||
// Idempotent upgrade: if the lead was reused from an earlier chat/
|
||||
// contact, promote its status and reflect the new interest.
|
||||
await this.updateLeadStatus(
|
||||
leadId,
|
||||
'APPOINTMENT_SET',
|
||||
req.chiefComplaint ?? 'Appointment Booking',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Widget lead upsert failed during booking: ${err}`);
|
||||
}
|
||||
|
||||
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||
@@ -129,24 +228,12 @@ export class WidgetService {
|
||||
}
|
||||
|
||||
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
|
||||
const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
|
||||
const firstName = req.name.split(' ')[0];
|
||||
const lastName = req.name.split(' ').slice(1).join(' ') || '';
|
||||
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: {
|
||||
name: req.name,
|
||||
contactName: { firstName, lastName },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
const leadId = await this.findOrCreateLeadByPhone(req.name, req.phone, {
|
||||
source: 'WEBSITE',
|
||||
status: 'NEW',
|
||||
interestedService: req.interest ?? 'Website Enquiry',
|
||||
} },
|
||||
this.auth,
|
||||
);
|
||||
|
||||
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
|
||||
return { leadId: data.createLead.id };
|
||||
});
|
||||
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
|
||||
return { leadId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"exclude": ["node_modules", "test", "dist", "widget-src", "public", "data", "**/*spec.ts"]
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
},
|
||||
"exclude": ["widget-src", "public", "data"]
|
||||
}
|
||||
|
||||
@@ -47,10 +47,28 @@ export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||
export const startChatSession = async (name: string, phone: string): Promise<{ leadId: string }> => {
|
||||
const res = await fetch(`${baseUrl}/api/widget/chat-start?key=${widgetKey}`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify({ name, phone }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Chat start failed');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Send the simplified {role, content: string}[] history to the backend.
|
||||
// Backend responds with an SSE stream of UIMessageChunk events.
|
||||
// branch (when set) is injected into the system prompt so the AI scopes
|
||||
// tool calls to that branch.
|
||||
type OutboundMessage = { role: 'user' | 'assistant'; content: string };
|
||||
export const streamChat = async (
|
||||
leadId: string,
|
||||
messages: OutboundMessage[],
|
||||
branch: string | null,
|
||||
): Promise<ReadableStream<Uint8Array>> => {
|
||||
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||
method: 'POST', headers: headers(),
|
||||
body: JSON.stringify({ messages, captchaToken }),
|
||||
body: JSON.stringify({ leadId, messages, branch }),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||
return res.body;
|
||||
|
||||
@@ -1,33 +1,83 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { fetchDoctors, fetchSlots, submitBooking } from './api';
|
||||
import { useState, useEffect, useMemo } from 'preact/hooks';
|
||||
import { fetchSlots, submitBooking } from './api';
|
||||
import { departmentIcon } from './icons';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { useWidgetStore } from './store';
|
||||
import type { Doctor, TimeSlot } from './types';
|
||||
|
||||
type Step = 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||
type Step = 'branch' | 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||
|
||||
export const Booking = () => {
|
||||
const [step, setStep] = useState<Step>('department');
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [departments, setDepartments] = useState<string[]>([]);
|
||||
const {
|
||||
visitor,
|
||||
updateVisitor,
|
||||
captchaToken,
|
||||
bookingPrefill,
|
||||
setBookingPrefill,
|
||||
doctors,
|
||||
doctorsLoading,
|
||||
doctorsError,
|
||||
branches,
|
||||
selectedBranch,
|
||||
setSelectedBranch,
|
||||
} = useWidgetStore();
|
||||
|
||||
// Start on the branch step only if the visitor actually has a choice to
|
||||
// make. Single-branch hospitals and chat-prefilled sessions skip it.
|
||||
const needsBranchStep = branches.length > 1 && !selectedBranch;
|
||||
const [step, setStep] = useState<Step>(needsBranchStep ? 'branch' : 'department');
|
||||
const [selectedDept, setSelectedDept] = useState('');
|
||||
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||
const [selectedSlot, setSelectedSlot] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [complaint, setComplaint] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reference, setReference] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDoctors().then(docs => {
|
||||
setDoctors(docs);
|
||||
setDepartments([...new Set(docs.map(d => d.department).filter(Boolean))]);
|
||||
}).catch(() => setError('Failed to load doctors'));
|
||||
}, []);
|
||||
// Scope the roster to the selected branch up front. Every downstream
|
||||
// derivation (departments list, doctor filter) works off this.
|
||||
const branchDoctors = useMemo(() => {
|
||||
if (!selectedBranch) return doctors;
|
||||
const needle = selectedBranch.toLowerCase();
|
||||
return doctors.filter(d =>
|
||||
String(d.clinic?.clinicName ?? '').toLowerCase().includes(needle),
|
||||
);
|
||||
}, [doctors, selectedBranch]);
|
||||
|
||||
const filteredDoctors = selectedDept ? doctors.filter(d => d.department === selectedDept) : [];
|
||||
// Derive department list from the branch-scoped roster.
|
||||
const departments = useMemo(
|
||||
() => [...new Set(branchDoctors.map(d => d.department).filter(Boolean))] as string[],
|
||||
[branchDoctors],
|
||||
);
|
||||
|
||||
const filteredDoctors = selectedDept
|
||||
? branchDoctors.filter(d => d.department === selectedDept)
|
||||
: [];
|
||||
|
||||
// Surface a doctors-load error if the roster failed to fetch.
|
||||
useEffect(() => {
|
||||
if (doctorsError) setError(doctorsError);
|
||||
}, [doctorsError]);
|
||||
|
||||
// Consume any booking prefill from chat → jump straight to the details form.
|
||||
// Also locks the branch to the picked doctor's clinic so the visitor sees
|
||||
// the right header badge when they land here.
|
||||
useEffect(() => {
|
||||
if (!bookingPrefill || doctors.length === 0) return;
|
||||
const doc = doctors.find(d => d.id === bookingPrefill.doctorId);
|
||||
if (!doc) return;
|
||||
if (doc.clinic?.clinicName && !selectedBranch) {
|
||||
setSelectedBranch(doc.clinic.clinicName);
|
||||
}
|
||||
setSelectedDept(doc.department);
|
||||
setSelectedDoctor(doc);
|
||||
setSelectedDate(bookingPrefill.date);
|
||||
setSelectedSlot(bookingPrefill.time);
|
||||
setStep('details');
|
||||
setBookingPrefill(null);
|
||||
}, [bookingPrefill, doctors]);
|
||||
|
||||
const handleDoctorSelect = (doc: Doctor) => {
|
||||
setSelectedDoctor(doc);
|
||||
@@ -42,7 +92,7 @@ export const Booking = () => {
|
||||
}, [selectedDoctor, selectedDate]);
|
||||
|
||||
const handleBook = async () => {
|
||||
if (!selectedDoctor || !selectedSlot || !name || !phone) return;
|
||||
if (!selectedDoctor || !selectedSlot || !visitor.name.trim() || !visitor.phone.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -51,10 +101,10 @@ export const Booking = () => {
|
||||
departmentId: selectedDept,
|
||||
doctorId: selectedDoctor.id,
|
||||
scheduledAt,
|
||||
patientName: name,
|
||||
patientPhone: phone,
|
||||
patientName: visitor.name.trim(),
|
||||
patientPhone: visitor.phone.trim(),
|
||||
chiefComplaint: complaint,
|
||||
captchaToken: 'dev-bypass',
|
||||
captchaToken,
|
||||
});
|
||||
setReference(result.reference);
|
||||
setStep('success');
|
||||
@@ -65,66 +115,117 @@ export const Booking = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stepIndex = { department: 0, doctor: 1, datetime: 2, details: 3, success: 4 };
|
||||
const currentStep = stepIndex[step];
|
||||
// Progress bar step count is dynamic: 5 dots if we need the branch step,
|
||||
// 4 otherwise. The current position is derived from the flow we're in.
|
||||
const flowSteps: Step[] = needsBranchStep
|
||||
? ['branch', 'department', 'doctor', 'datetime', 'details']
|
||||
: ['department', 'doctor', 'datetime', 'details'];
|
||||
const currentStep = flowSteps.indexOf(step);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step !== 'success' && (
|
||||
<div class="widget-steps">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
{flowSteps.map((_, i) => (
|
||||
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||
{error && <div class="widget-error">{error}</div>}
|
||||
|
||||
{step === 'department' && (
|
||||
{step === 'branch' && (
|
||||
<div>
|
||||
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Select Department</div>
|
||||
{departments.map(dept => (
|
||||
<div class="widget-section-title">Select Branch</div>
|
||||
{doctorsLoading && branches.length === 0 && (
|
||||
<div class="widget-section-sub">Loading…</div>
|
||||
)}
|
||||
{branches.map(branch => (
|
||||
<button
|
||||
key={dept}
|
||||
class="widget-btn widget-btn-secondary"
|
||||
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||
key={branch}
|
||||
class="widget-row-btn"
|
||||
onClick={() => {
|
||||
setSelectedBranch(branch);
|
||||
setStep('department');
|
||||
}}
|
||||
>
|
||||
{dept.replace(/_/g, ' ')}
|
||||
<IconSpan class="widget-row-icon" name="hospital" size={20} />
|
||||
<span class="widget-row-label">{branch}</span>
|
||||
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'department' && (
|
||||
<div>
|
||||
<div class="widget-section-title">
|
||||
{selectedBranch && (
|
||||
<>
|
||||
<IconSpan class="widget-row-icon" name="hospital" size={16} />
|
||||
{selectedBranch} —
|
||||
</>
|
||||
)}
|
||||
Select Department
|
||||
</div>
|
||||
{doctorsLoading && departments.length === 0 && (
|
||||
<div class="widget-section-sub">Loading…</div>
|
||||
)}
|
||||
{departments.map(dept => (
|
||||
<button
|
||||
key={dept}
|
||||
class="widget-row-btn"
|
||||
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||
>
|
||||
<IconSpan class="widget-row-icon" name={departmentIcon(dept)} size={20} />
|
||||
<span class="widget-row-label">{dept.replace(/_/g, ' ')}</span>
|
||||
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
|
||||
</button>
|
||||
))}
|
||||
{branches.length > 1 && (
|
||||
<button
|
||||
class="widget-btn widget-btn-secondary widget-btn-with-icon"
|
||||
style={{ marginTop: '8px' }}
|
||||
onClick={() => setStep('branch')}
|
||||
>
|
||||
<IconSpan name="arrow-left" size={14} />
|
||||
Change branch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'doctor' && (
|
||||
<div>
|
||||
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||
Select Doctor — {selectedDept.replace(/_/g, ' ')}
|
||||
<div class="widget-section-title">
|
||||
<IconSpan class="widget-row-icon" name={departmentIcon(selectedDept)} size={16} />
|
||||
{selectedDept.replace(/_/g, ' ')}
|
||||
</div>
|
||||
{filteredDoctors.map(doc => (
|
||||
<button
|
||||
key={doc.id}
|
||||
class="widget-btn widget-btn-secondary"
|
||||
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||
class="widget-row-btn widget-row-btn-stack"
|
||||
onClick={() => handleDoctorSelect(doc)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||
<div style={{ fontSize: '11px', color: '#6b7280' }}>
|
||||
<div class="widget-row-main">
|
||||
<div class="widget-row-label">{doc.name}</div>
|
||||
<div class="widget-row-sub">
|
||||
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `• ${doc.clinic.clinicName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<IconSpan class="widget-row-chevron" name="arrow-right" size={14} />
|
||||
</button>
|
||||
))}
|
||||
<button class="widget-btn widget-btn-secondary" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||
← Back
|
||||
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||
<IconSpan name="arrow-left" size={14} />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'datetime' && (
|
||||
<div>
|
||||
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||
{selectedDoctor?.name} — Pick Date & Time
|
||||
</div>
|
||||
<div class="widget-section-title">{selectedDoctor?.name} — Pick Date & Time</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Date</label>
|
||||
<input
|
||||
@@ -152,31 +253,59 @@ export const Booking = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('doctor')}>← Back</button>
|
||||
<button class="widget-btn" style={{ flex: 1 }} disabled={!selectedSlot} onClick={() => setStep('details')}>Next →</button>
|
||||
<div class="widget-btn-row">
|
||||
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" onClick={() => setStep('doctor')}>
|
||||
<IconSpan name="arrow-left" size={14} />
|
||||
Back
|
||||
</button>
|
||||
<button class="widget-btn widget-btn-with-icon" disabled={!selectedSlot} onClick={() => setStep('details')}>
|
||||
Next
|
||||
<IconSpan name="arrow-right" size={14} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'details' && (
|
||||
<div>
|
||||
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Your Details</div>
|
||||
<div class="widget-section-title">Your Details</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Full Name *</label>
|
||||
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="Your name"
|
||||
value={visitor.name}
|
||||
onInput={(e: any) => updateVisitor({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Phone Number *</label>
|
||||
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="+91 9876543210"
|
||||
value={visitor.phone}
|
||||
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Chief Complaint</label>
|
||||
<textarea class="widget-input widget-textarea" placeholder="Describe your concern..." value={complaint} onInput={(e: any) => setComplaint(e.target.value)} />
|
||||
<textarea
|
||||
class="widget-input widget-textarea"
|
||||
placeholder="Describe your concern..."
|
||||
value={complaint}
|
||||
onInput={(e: any) => setComplaint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('datetime')}>← Back</button>
|
||||
<button class="widget-btn" style={{ flex: 1 }} disabled={!name || !phone || loading} onClick={handleBook}>
|
||||
<div class="widget-btn-row">
|
||||
<button class="widget-btn widget-btn-secondary widget-btn-with-icon" onClick={() => setStep('datetime')}>
|
||||
<IconSpan name="arrow-left" size={14} />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
class="widget-btn"
|
||||
disabled={!visitor.name.trim() || !visitor.phone.trim() || loading}
|
||||
onClick={handleBook}
|
||||
>
|
||||
{loading ? 'Booking...' : 'Book Appointment'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -185,7 +314,9 @@ export const Booking = () => {
|
||||
|
||||
{step === 'success' && (
|
||||
<div class="widget-success">
|
||||
<div class="widget-success-icon">✅</div>
|
||||
<div class="widget-success-icon">
|
||||
<IconSpan name="circle-check" size={56} color="#059669" />
|
||||
</div>
|
||||
<div class="widget-success-title">Appointment Booked!</div>
|
||||
<div class="widget-success-text">
|
||||
Reference: <strong>{reference}</strong><br />
|
||||
|
||||
164
widget-src/src/captcha.tsx
Normal file
164
widget-src/src/captcha.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
// Cloudflare Turnstile integration.
|
||||
//
|
||||
// Rendering strategy: Turnstile injects its layout stylesheet into document.head,
|
||||
// which does NOT cascade into our shadow DOM. When rendered inside our shadow DOM
|
||||
// the iframe exists with correct attributes but paints as zero pixels because the
|
||||
// wrapper Turnstile creates has no resolved styles. To work around this we mount
|
||||
// Turnstile into a portal div appended to document.body (light DOM), then use
|
||||
// getBoundingClientRect on the in-shadow placeholder to keep the portal visually
|
||||
// overlaid on top of the captcha gate area.
|
||||
|
||||
type TurnstileOptions = {
|
||||
sitekey: string;
|
||||
callback?: (token: string) => void;
|
||||
'error-callback'?: () => void;
|
||||
'expired-callback'?: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible' | 'invisible';
|
||||
appearance?: 'always' | 'execute' | 'interaction-only';
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (container: HTMLElement, opts: TurnstileOptions) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
reset: (widgetId?: string) => void;
|
||||
};
|
||||
__helixTurnstileLoading?: Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
export const loadTurnstile = (): Promise<void> => {
|
||||
if (typeof window === 'undefined') return Promise.resolve();
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
if (window.__helixTurnstileLoading) return window.__helixTurnstileLoading;
|
||||
|
||||
window.__helixTurnstileLoading = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
const poll = () => {
|
||||
if (window.turnstile) resolve();
|
||||
else setTimeout(poll, 50);
|
||||
};
|
||||
poll();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Turnstile failed to load'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return window.__helixTurnstileLoading;
|
||||
};
|
||||
|
||||
type CaptchaProps = {
|
||||
siteKey: string;
|
||||
onToken: (token: string) => void;
|
||||
onError?: () => void;
|
||||
};
|
||||
|
||||
export const Captcha = ({ siteKey, onToken, onError }: CaptchaProps) => {
|
||||
const placeholderRef = useRef<HTMLDivElement | null>(null);
|
||||
const portalRef = useRef<HTMLDivElement | null>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const onTokenRef = useRef(onToken);
|
||||
const onErrorRef = useRef(onError);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
|
||||
onTokenRef.current = onToken;
|
||||
onErrorRef.current = onError;
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !placeholderRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Light-DOM portal so Turnstile's document.head styles actually apply.
|
||||
const portal = document.createElement('div');
|
||||
portal.setAttribute('data-helix-turnstile', '');
|
||||
portal.style.cssText = [
|
||||
'position:fixed',
|
||||
'z-index:2147483647',
|
||||
'width:300px',
|
||||
'height:65px',
|
||||
'pointer-events:auto',
|
||||
].join(';');
|
||||
document.body.appendChild(portal);
|
||||
portalRef.current = portal;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!placeholderRef.current || !portalRef.current) return;
|
||||
const rect = placeholderRef.current.getBoundingClientRect();
|
||||
portalRef.current.style.top = `${rect.top}px`;
|
||||
portalRef.current.style.left = `${rect.left}px`;
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
|
||||
// Reposition on animation frame while the gate is mounted, so the portal
|
||||
// tracks the placeholder through panel open animation and any layout shifts.
|
||||
let rafId = 0;
|
||||
const trackLoop = () => {
|
||||
updatePosition();
|
||||
rafId = requestAnimationFrame(trackLoop);
|
||||
};
|
||||
rafId = requestAnimationFrame(trackLoop);
|
||||
|
||||
loadTurnstile()
|
||||
.then(() => {
|
||||
if (cancelled || !portalRef.current || !window.turnstile) return;
|
||||
try {
|
||||
widgetIdRef.current = window.turnstile.render(portalRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (token) => onTokenRef.current(token),
|
||||
'error-callback': () => {
|
||||
setStatus('error');
|
||||
onErrorRef.current?.();
|
||||
},
|
||||
'expired-callback': () => onTokenRef.current(''),
|
||||
theme: 'light',
|
||||
size: 'normal',
|
||||
});
|
||||
setStatus('ready');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
onErrorRef.current?.();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('error');
|
||||
onErrorRef.current?.();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
} catch {
|
||||
// ignore — widget may already be gone
|
||||
}
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
portal.remove();
|
||||
portalRef.current = null;
|
||||
};
|
||||
}, [siteKey]);
|
||||
|
||||
return (
|
||||
<div class="widget-captcha">
|
||||
<div class="widget-captcha-mount" ref={placeholderRef} />
|
||||
{status === 'loading' && <div class="widget-captcha-status">Loading verification…</div>}
|
||||
{status === 'error' && <div class="widget-captcha-status widget-captcha-error">Verification failed to load. Please refresh.</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
widget-src/src/chat-stream.ts
Normal file
61
widget-src/src/chat-stream.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Minimal SSE + UIMessageChunk parser. The backend writes
|
||||
// data: ${JSON.stringify(chunk)}\n\n
|
||||
// for each AI SDK UIMessageChunk, plus a final `data: [DONE]\n\n`.
|
||||
// We reconstruct events by buffering stream text and splitting on blank lines.
|
||||
|
||||
export type UIMessageChunk =
|
||||
| { type: 'start'; messageId?: string }
|
||||
| { type: 'start-step' }
|
||||
| { type: 'finish-step' }
|
||||
| { type: 'finish' }
|
||||
| { type: 'error'; errorText: string }
|
||||
| { type: 'text-start'; id: string }
|
||||
| { type: 'text-delta'; id: string; delta: string }
|
||||
| { type: 'text-end'; id: string }
|
||||
| { type: 'tool-input-start'; toolCallId: string; toolName: string }
|
||||
| { type: 'tool-input-delta'; toolCallId: string; inputTextDelta: string }
|
||||
| { type: 'tool-input-available'; toolCallId: string; toolName: string; input: any }
|
||||
| { type: 'tool-output-available'; toolCallId: string; output: any }
|
||||
| { type: 'tool-output-error'; toolCallId: string; errorText: string }
|
||||
| { type: string; [key: string]: any };
|
||||
|
||||
// Reads the SSE body byte stream and yields UIMessageChunk objects.
|
||||
export async function* readChatStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
): AsyncGenerator<UIMessageChunk> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Each SSE event is terminated by a blank line. Split off complete
|
||||
// events and keep the trailing partial in buffer.
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
||||
const rawEvent = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
|
||||
// Grab lines starting with "data:" (there may be comments or
|
||||
// event: lines too — we ignore them).
|
||||
const lines = rawEvent.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trimStart();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
yield JSON.parse(payload) as UIMessageChunk;
|
||||
} catch {
|
||||
// Bad JSON — skip this event rather than crash the stream.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
274
widget-src/src/chat-widgets.tsx
Normal file
274
widget-src/src/chat-widgets.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { IconSpan } from './icon-span';
|
||||
import { departmentIcon } from './icons';
|
||||
import type { BookingPrefill, ChatToolPart, ToolOutputs } from './types';
|
||||
|
||||
type WidgetProps = {
|
||||
part: ChatToolPart;
|
||||
onDepartmentClick: (department: string) => void;
|
||||
onShowDoctorSlots: (doctorName: string) => void;
|
||||
onSuggestBooking: () => void;
|
||||
onPickSlot: (prefill: BookingPrefill) => void;
|
||||
onPickBranch: (branch: string) => void;
|
||||
};
|
||||
|
||||
// Dispatcher — renders the right widget for a tool part based on its name and state.
|
||||
export const ChatToolWidget = ({
|
||||
part,
|
||||
onDepartmentClick,
|
||||
onShowDoctorSlots,
|
||||
onSuggestBooking,
|
||||
onPickSlot,
|
||||
onPickBranch,
|
||||
}: WidgetProps) => {
|
||||
if (part.state === 'input-streaming' || part.state === 'input-available') {
|
||||
return <ToolLoadingRow toolName={part.toolName} />;
|
||||
}
|
||||
if (part.state === 'output-error') {
|
||||
return <div class="chat-widget-error">Couldn't load: {part.errorText ?? 'unknown error'}</div>;
|
||||
}
|
||||
|
||||
switch (part.toolName) {
|
||||
case 'pick_branch': {
|
||||
const out = part.output as ToolOutputs['pick_branch'] | undefined;
|
||||
if (!out?.branches?.length) return null;
|
||||
return <BranchPickerWidget branches={out.branches} onPick={onPickBranch} />;
|
||||
}
|
||||
case 'list_departments': {
|
||||
const out = part.output as ToolOutputs['list_departments'] | undefined;
|
||||
if (!out?.departments?.length) return null;
|
||||
return <DepartmentListWidget departments={out.departments} onPick={onDepartmentClick} />;
|
||||
}
|
||||
case 'show_clinic_timings': {
|
||||
const out = part.output as ToolOutputs['show_clinic_timings'] | undefined;
|
||||
if (!out?.departments?.length) return null;
|
||||
return <ClinicTimingsWidget departments={out.departments} />;
|
||||
}
|
||||
case 'show_doctors': {
|
||||
const out = part.output as ToolOutputs['show_doctors'] | undefined;
|
||||
if (!out?.doctors?.length) {
|
||||
return (
|
||||
<div class="chat-widget-empty">
|
||||
No doctors found in {out?.department ?? 'this department'}.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DoctorsListWidget
|
||||
department={out.department}
|
||||
doctors={out.doctors}
|
||||
onPickDoctor={onShowDoctorSlots}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'show_doctor_slots': {
|
||||
const out = part.output as ToolOutputs['show_doctor_slots'] | undefined;
|
||||
if (!out) return null;
|
||||
if (out.error || !out.doctor) {
|
||||
return <div class="chat-widget-empty">{out.error ?? 'Doctor not found.'}</div>;
|
||||
}
|
||||
return <DoctorSlotsWidget data={out} onPickSlot={onPickSlot} />;
|
||||
}
|
||||
case 'suggest_booking': {
|
||||
const out = part.output as ToolOutputs['suggest_booking'] | undefined;
|
||||
return (
|
||||
<BookingSuggestionWidget
|
||||
reason={out?.reason ?? 'Book an appointment.'}
|
||||
department={out?.department ?? null}
|
||||
onBook={onSuggestBooking}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
pick_branch: 'Fetching branches…',
|
||||
list_departments: 'Looking up departments…',
|
||||
show_clinic_timings: 'Fetching clinic hours…',
|
||||
show_doctors: 'Looking up doctors…',
|
||||
show_doctor_slots: 'Checking availability…',
|
||||
suggest_booking: 'Thinking about booking options…',
|
||||
};
|
||||
|
||||
const ToolLoadingRow = ({ toolName }: { toolName: string }) => (
|
||||
<div class="chat-widget-loading">
|
||||
<span class="chat-typing-dots" aria-hidden="true">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
<span class="chat-widget-loading-label">{TOOL_LABELS[toolName] ?? 'Working…'}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
type BranchPickerProps = {
|
||||
branches: ToolOutputs['pick_branch']['branches'];
|
||||
onPick: (branch: string) => void;
|
||||
};
|
||||
|
||||
const BranchPickerWidget = ({ branches, onPick }: BranchPickerProps) => (
|
||||
<div class="chat-widget chat-widget-branches">
|
||||
<div class="chat-widget-title">Which branch?</div>
|
||||
{branches.map(b => (
|
||||
<button key={b.name} class="chat-widget-branch-card" onClick={() => onPick(b.name)}>
|
||||
<div class="chat-widget-branch-name">{b.name}</div>
|
||||
<div class="chat-widget-branch-meta">
|
||||
{b.doctorCount} {b.doctorCount === 1 ? 'doctor' : 'doctors'}
|
||||
{b.departmentCount > 0 ? ` • ${b.departmentCount} ${b.departmentCount === 1 ? 'department' : 'departments'}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type DepartmentListProps = {
|
||||
departments: string[];
|
||||
onPick: (department: string) => void;
|
||||
};
|
||||
|
||||
const DepartmentListWidget = ({ departments, onPick }: DepartmentListProps) => (
|
||||
<div class="chat-widget chat-widget-departments">
|
||||
<div class="chat-widget-title">Departments</div>
|
||||
<div class="chat-widget-dept-grid">
|
||||
{departments.map(dept => (
|
||||
<button
|
||||
key={dept}
|
||||
class="chat-widget-dept-chip"
|
||||
onClick={() => onPick(dept)}
|
||||
title={`Show doctors in ${dept}`}
|
||||
>
|
||||
<IconSpan name={departmentIcon(dept)} size={16} />
|
||||
<span>{dept}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type ClinicTimingsProps = {
|
||||
departments: ToolOutputs['show_clinic_timings']['departments'];
|
||||
};
|
||||
|
||||
const ClinicTimingsWidget = ({ departments }: ClinicTimingsProps) => (
|
||||
<div class="chat-widget chat-widget-timings">
|
||||
<div class="chat-widget-title">
|
||||
<IconSpan name="calendar" size={14} /> Clinic hours
|
||||
</div>
|
||||
{departments.map(dept => (
|
||||
<div key={dept.name} class="chat-widget-timing-dept">
|
||||
<div class="chat-widget-timing-dept-name">
|
||||
<IconSpan name={departmentIcon(dept.name)} size={14} />
|
||||
<span>{dept.name}</span>
|
||||
</div>
|
||||
{dept.entries.map(entry => (
|
||||
<div key={`${dept.name}-${entry.name}`} class="chat-widget-timing-row">
|
||||
<div class="chat-widget-timing-doctor">{entry.name}</div>
|
||||
<div class="chat-widget-timing-hours">{entry.hours}</div>
|
||||
{entry.clinic && (
|
||||
<div class="chat-widget-timing-clinic">{entry.clinic}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type DoctorsListProps = {
|
||||
department: string;
|
||||
doctors: ToolOutputs['show_doctors']['doctors'];
|
||||
onPickDoctor: (doctorName: string) => void;
|
||||
};
|
||||
|
||||
const DoctorsListWidget = ({ department, doctors, onPickDoctor }: DoctorsListProps) => (
|
||||
<div class="chat-widget chat-widget-doctors">
|
||||
<div class="chat-widget-title">
|
||||
<IconSpan name={departmentIcon(department)} size={14} /> {department}
|
||||
</div>
|
||||
{doctors.map(doc => (
|
||||
<div key={doc.id} class="chat-widget-doctor-card">
|
||||
<div class="chat-widget-doctor-name">{doc.name}</div>
|
||||
{doc.specialty && <div class="chat-widget-doctor-meta">{doc.specialty}</div>}
|
||||
{doc.visitingHours && <div class="chat-widget-doctor-meta">{doc.visitingHours}</div>}
|
||||
{doc.clinic && <div class="chat-widget-doctor-meta">{doc.clinic}</div>}
|
||||
<button
|
||||
class="chat-widget-doctor-action"
|
||||
onClick={() => onPickDoctor(doc.name)}
|
||||
>
|
||||
<IconSpan name="calendar" size={12} /> See available appointments
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type DoctorSlotsProps = {
|
||||
data: ToolOutputs['show_doctor_slots'];
|
||||
onPickSlot: (prefill: BookingPrefill) => void;
|
||||
};
|
||||
|
||||
const DoctorSlotsWidget = ({ data, onPickSlot }: DoctorSlotsProps) => {
|
||||
if (!data.doctor) return null;
|
||||
const doctor = data.doctor;
|
||||
const available = data.slots.filter(s => s.available);
|
||||
const hasAny = available.length > 0;
|
||||
|
||||
return (
|
||||
<div class="chat-widget chat-widget-slots">
|
||||
<div class="chat-widget-title">
|
||||
<IconSpan name="calendar" size={14} /> Available slots
|
||||
</div>
|
||||
<div class="chat-widget-slots-doctor">{doctor.name}</div>
|
||||
<div class="chat-widget-slots-meta">
|
||||
{formatDate(data.date)}
|
||||
{doctor.clinic ? ` • ${doctor.clinic}` : ''}
|
||||
</div>
|
||||
{hasAny ? (
|
||||
<div class="chat-widget-slots-grid">
|
||||
{data.slots.map(s => (
|
||||
<button
|
||||
key={s.time}
|
||||
class={`chat-widget-slot-btn ${s.available ? '' : 'unavailable'}`}
|
||||
disabled={!s.available}
|
||||
onClick={() =>
|
||||
s.available &&
|
||||
onPickSlot({ doctorId: doctor.id, date: data.date, time: s.time })
|
||||
}
|
||||
>
|
||||
{s.time}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="chat-widget-empty">No slots available on this date.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (iso: string): string => {
|
||||
// iso is YYYY-MM-DD from the backend. Render as e.g. "Mon, 6 Apr".
|
||||
const d = new Date(iso + 'T00:00:00');
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
type BookingSuggestionProps = {
|
||||
reason: string;
|
||||
department: string | null;
|
||||
onBook: () => void;
|
||||
};
|
||||
|
||||
const BookingSuggestionWidget = ({ reason, department, onBook }: BookingSuggestionProps) => (
|
||||
<div class="chat-widget chat-widget-booking">
|
||||
<div class="chat-widget-booking-icon">
|
||||
<IconSpan name="calendar" size={28} />
|
||||
</div>
|
||||
<div class="chat-widget-booking-body">
|
||||
<div class="chat-widget-booking-title">Book an appointment</div>
|
||||
<div class="chat-widget-booking-reason">{reason}</div>
|
||||
{department && <div class="chat-widget-booking-dept">Suggested: {department}</div>}
|
||||
<button class="widget-btn" onClick={onBook}>Book now</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,18 +1,85 @@
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
import { streamChat } from './api';
|
||||
import type { ChatMessage } from './types';
|
||||
import { startChatSession, streamChat } from './api';
|
||||
import { readChatStream } from './chat-stream';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { ChatToolWidget } from './chat-widgets';
|
||||
import { useWidgetStore } from './store';
|
||||
import type {
|
||||
BookingPrefill,
|
||||
ChatMessage,
|
||||
ChatPart,
|
||||
ChatTextPart,
|
||||
ChatToolPart,
|
||||
} from './types';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
'Doctor availability',
|
||||
'What departments do you have?',
|
||||
'Show me cardiologists',
|
||||
'Clinic timings',
|
||||
'Book appointment',
|
||||
'Health packages',
|
||||
'How do I book?',
|
||||
];
|
||||
|
||||
export const Chat = () => {
|
||||
type ChatProps = {
|
||||
// Switches the widget to the Book tab. Chat-level handler that lives in
|
||||
// the parent so slot picks can seed bookingPrefill + swap tabs atomically.
|
||||
onRequestBooking: (prefill?: BookingPrefill) => void;
|
||||
};
|
||||
|
||||
const textOf = (msg: ChatMessage): string =>
|
||||
msg.parts
|
||||
.filter((p): p is ChatTextPart => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
|
||||
const updateMessage = (
|
||||
setMessages: (updater: (msgs: ChatMessage[]) => ChatMessage[]) => void,
|
||||
id: string,
|
||||
mutator: (msg: ChatMessage) => ChatMessage,
|
||||
) => {
|
||||
setMessages(prev => prev.map(m => (m.id === id ? mutator(m) : m)));
|
||||
};
|
||||
|
||||
const appendTextDelta = (parts: ChatPart[], delta: string, state: 'streaming' | 'done'): ChatPart[] => {
|
||||
const last = parts[parts.length - 1];
|
||||
if (last?.type === 'text') {
|
||||
return [...parts.slice(0, -1), { type: 'text', text: last.text + delta, state }];
|
||||
}
|
||||
return [...parts, { type: 'text', text: delta, state }];
|
||||
};
|
||||
|
||||
const upsertToolPart = (
|
||||
parts: ChatPart[],
|
||||
toolCallId: string,
|
||||
update: Partial<ChatToolPart>,
|
||||
fallback: ChatToolPart,
|
||||
): ChatPart[] => {
|
||||
const idx = parts.findIndex(p => p.type === 'tool' && p.toolCallId === toolCallId);
|
||||
if (idx >= 0) {
|
||||
const existing = parts[idx] as ChatToolPart;
|
||||
const merged: ChatToolPart = { ...existing, ...update };
|
||||
return [...parts.slice(0, idx), merged, ...parts.slice(idx + 1)];
|
||||
}
|
||||
return [...parts, { ...fallback, ...update }];
|
||||
};
|
||||
|
||||
const genId = () => `m_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export const Chat = ({ onRequestBooking }: ChatProps) => {
|
||||
const {
|
||||
visitor,
|
||||
updateVisitor,
|
||||
leadId,
|
||||
setLeadId,
|
||||
setBookingPrefill,
|
||||
selectedBranch,
|
||||
setSelectedBranch,
|
||||
} = useWidgetStore();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,59 +88,263 @@ export const Chat = () => {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
const submitLeadForm = async () => {
|
||||
const name = visitor.name.trim();
|
||||
const phone = visitor.phone.trim();
|
||||
if (!name || !phone) return;
|
||||
setFormSubmitting(true);
|
||||
setFormError('');
|
||||
try {
|
||||
const { leadId: newLeadId } = await startChatSession(name, phone);
|
||||
setLeadId(newLeadId);
|
||||
} catch {
|
||||
setFormError('Could not start chat. Please try again.');
|
||||
} finally {
|
||||
setFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
const sendMessage = async (text: string, branchOverride?: string | null) => {
|
||||
if (!text.trim() || loading || !leadId) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: text.trim(), state: 'done' }],
|
||||
};
|
||||
const assistantId = genId();
|
||||
const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', parts: [] };
|
||||
|
||||
const historyForBackend = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: textOf(m),
|
||||
}));
|
||||
|
||||
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
// Branch can be provided explicitly to bypass the stale closure value
|
||||
// when the caller just set it (e.g., handleBranchPick immediately after
|
||||
// setSelectedBranch).
|
||||
const effectiveBranch =
|
||||
branchOverride !== undefined ? branchOverride : selectedBranch;
|
||||
|
||||
try {
|
||||
const stream = await streamChat(updated);
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantText = '';
|
||||
const stream = await streamChat(leadId, historyForBackend, effectiveBranch);
|
||||
|
||||
setMessages([...updated, { role: 'assistant', content: '' }]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
assistantText += decoder.decode(value, { stream: true });
|
||||
setMessages([...updated, { role: 'assistant', content: assistantText }]);
|
||||
for await (const chunk of readChatStream(stream)) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
if (typeof chunk.delta === 'string') {
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: appendTextDelta(m.parts, chunk.delta, 'streaming'),
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case 'text-end':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: m.parts.map(p =>
|
||||
p.type === 'text' ? { ...p, state: 'done' } : p,
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-input-start':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'input-streaming', toolName: chunk.toolName },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
state: 'input-streaming',
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-input-available':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'input-available', toolName: chunk.toolName, input: chunk.input },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
state: 'input-available',
|
||||
input: chunk.input,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-output-available':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'output-available', output: chunk.output },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: 'unknown',
|
||||
state: 'output-available',
|
||||
output: chunk.output,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-output-error':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'output-error', errorText: chunk.errorText },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: 'unknown',
|
||||
state: 'output-error',
|
||||
errorText: chunk.errorText,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'error':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: [
|
||||
...m.parts,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Sorry, I encountered an error. Please try again.',
|
||||
state: 'done',
|
||||
},
|
||||
],
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: [
|
||||
...m.parts,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Sorry, I encountered an error. Please try again.',
|
||||
state: 'done',
|
||||
},
|
||||
],
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotPick = (prefill: BookingPrefill) => {
|
||||
setBookingPrefill(prefill);
|
||||
onRequestBooking(prefill);
|
||||
};
|
||||
|
||||
const handleBranchPick = (branch: string) => {
|
||||
// Store the selection so every subsequent request carries it, then
|
||||
// echo the visitor's choice as a user message so the AI re-runs the
|
||||
// branch-gated tool it was about to call. We pass branch explicitly
|
||||
// to sidestep the stale-closure selectedBranch inside sendMessage.
|
||||
setSelectedBranch(branch);
|
||||
sendMessage(`I'm interested in the ${branch} branch.`, branch);
|
||||
};
|
||||
|
||||
// Pre-chat gate — only shown if we don't yet have an active lead. Name/phone
|
||||
// inputs bind to the shared store so anything typed here is immediately
|
||||
// available to the Book and Contact forms too.
|
||||
if (!leadId) {
|
||||
return (
|
||||
<div class="chat-intro">
|
||||
<div class="chat-empty-icon">
|
||||
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
|
||||
</div>
|
||||
<div class="chat-empty-title">Hi! How can we help?</div>
|
||||
<div class="chat-empty-text">
|
||||
Share your name and phone so we can follow up if needed.
|
||||
</div>
|
||||
{formError && <div class="widget-error">{formError}</div>}
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Full Name *</label>
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="Your name"
|
||||
value={visitor.name}
|
||||
onInput={(e: any) => updateVisitor({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Phone Number *</label>
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="+91 9876543210"
|
||||
value={visitor.phone}
|
||||
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
|
||||
onKeyDown={(e: any) => e.key === 'Enter' && submitLeadForm()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="widget-btn"
|
||||
onClick={submitLeadForm}
|
||||
disabled={!visitor.name.trim() || !visitor.phone.trim() || formSubmitting}
|
||||
>
|
||||
{formSubmitting ? 'Starting…' : 'Start Chat'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div class="chat-messages" ref={scrollRef}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
|
||||
How can we help you?
|
||||
<div class="chat-empty">
|
||||
<div class="chat-empty-icon">
|
||||
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||
<div class="chat-empty-title">
|
||||
Hi {visitor.name.split(' ')[0] || 'there'}, how can we help?
|
||||
</div>
|
||||
<div class="chat-empty-text">
|
||||
Ask about doctors, clinics, packages, or book an appointment.
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
{QUICK_ACTIONS.map(q => (
|
||||
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
|
||||
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} class={`chat-msg ${msg.role}`}>
|
||||
<div class="chat-bubble">{msg.content || '...'}</div>
|
||||
</div>
|
||||
{messages.map(msg => (
|
||||
<MessageRow
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onDepartmentClick={dept => sendMessage(`Show me doctors in ${dept}`)}
|
||||
onShowDoctorSlots={doctorName =>
|
||||
sendMessage(`Show available appointments for ${doctorName}`)
|
||||
}
|
||||
onSuggestBooking={() => onRequestBooking()}
|
||||
onPickSlot={handleSlotPick}
|
||||
onPickBranch={handleBranchPick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
@@ -85,10 +356,80 @@ export const Chat = () => {
|
||||
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
|
||||
↑
|
||||
<button
|
||||
class="chat-send"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={loading || !input.trim()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IconSpan name="paper-plane-top" size={16} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MessageRowProps = {
|
||||
msg: ChatMessage;
|
||||
onDepartmentClick: (dept: string) => void;
|
||||
onShowDoctorSlots: (doctorName: string) => void;
|
||||
onSuggestBooking: () => void;
|
||||
onPickSlot: (prefill: BookingPrefill) => void;
|
||||
onPickBranch: (branch: string) => void;
|
||||
};
|
||||
|
||||
const MessageRow = ({
|
||||
msg,
|
||||
onDepartmentClick,
|
||||
onShowDoctorSlots,
|
||||
onSuggestBooking,
|
||||
onPickSlot,
|
||||
onPickBranch,
|
||||
}: MessageRowProps) => {
|
||||
const isEmptyAssistant = msg.role === 'assistant' && msg.parts.length === 0;
|
||||
|
||||
// If any tool parts exist, hide text parts from the same turn to avoid
|
||||
// models restating the widget's contents in prose.
|
||||
const hasToolParts = msg.parts.some(p => p.type === 'tool');
|
||||
const visibleParts = hasToolParts
|
||||
? msg.parts.filter(p => p.type === 'tool')
|
||||
: msg.parts;
|
||||
|
||||
return (
|
||||
<div class={`chat-msg ${msg.role}`}>
|
||||
<div class="chat-msg-stack">
|
||||
{isEmptyAssistant && (
|
||||
<div class="chat-bubble">
|
||||
<TypingDots />
|
||||
</div>
|
||||
)}
|
||||
{visibleParts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={i} class="chat-bubble">
|
||||
{part.text || <TypingDots />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChatToolWidget
|
||||
key={i}
|
||||
part={part}
|
||||
onDepartmentClick={onDepartmentClick}
|
||||
onShowDoctorSlots={onShowDoctorSlots}
|
||||
onSuggestBooking={onSuggestBooking}
|
||||
onPickSlot={onPickSlot}
|
||||
onPickBranch={onPickBranch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TypingDots = () => (
|
||||
<span class="chat-typing-dots" aria-label="Assistant is typing">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { submitLead } from './api';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { useWidgetStore } from './store';
|
||||
|
||||
export const Contact = () => {
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const { visitor, updateVisitor, captchaToken } = useWidgetStore();
|
||||
|
||||
const [interest, setInterest] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -11,16 +13,16 @@ export const Contact = () => {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !phone.trim()) return;
|
||||
if (!visitor.name.trim() || !visitor.phone.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await submitLead({
|
||||
name: name.trim(),
|
||||
phone: phone.trim(),
|
||||
name: visitor.name.trim(),
|
||||
phone: visitor.phone.trim(),
|
||||
interest: interest.trim() || undefined,
|
||||
message: message.trim() || undefined,
|
||||
captchaToken: 'dev-bypass',
|
||||
captchaToken,
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch {
|
||||
@@ -33,10 +35,12 @@ export const Contact = () => {
|
||||
if (success) {
|
||||
return (
|
||||
<div class="widget-success">
|
||||
<div class="widget-success-icon">🙏</div>
|
||||
<div class="widget-success-icon">
|
||||
<IconSpan name="hands-praying" size={56} color="#059669" />
|
||||
</div>
|
||||
<div class="widget-success-title">Thank you!</div>
|
||||
<div class="widget-success-text">
|
||||
An agent will call you shortly on {phone}.<br />
|
||||
An agent will call you shortly on {visitor.phone}.<br />
|
||||
We typically respond within 30 minutes during business hours.
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,22 +49,28 @@ export const Contact = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: '#1f2937', marginBottom: '12px' }}>
|
||||
Get in touch
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||
Leave your details and we'll call you back.
|
||||
</div>
|
||||
<div class="widget-section-title">Get in touch</div>
|
||||
<div class="widget-section-sub">Leave your details and we'll call you back.</div>
|
||||
|
||||
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||
{error && <div class="widget-error">{error}</div>}
|
||||
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Full Name *</label>
|
||||
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="Your name"
|
||||
value={visitor.name}
|
||||
onInput={(e: any) => updateVisitor({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Phone Number *</label>
|
||||
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="+91 9876543210"
|
||||
value={visitor.phone}
|
||||
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Interested In</label>
|
||||
@@ -75,9 +85,18 @@ export const Contact = () => {
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Message</label>
|
||||
<textarea class="widget-input widget-textarea" placeholder="How can we help? (optional)" value={message} onInput={(e: any) => setMessage(e.target.value)} />
|
||||
<textarea
|
||||
class="widget-input widget-textarea"
|
||||
placeholder="How can we help? (optional)"
|
||||
value={message}
|
||||
onInput={(e: any) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button class="widget-btn" disabled={!name.trim() || !phone.trim() || loading} onClick={handleSubmit}>
|
||||
<button
|
||||
class="widget-btn"
|
||||
disabled={!visitor.name.trim() || !visitor.phone.trim() || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Message'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
15
widget-src/src/icon-span.tsx
Normal file
15
widget-src/src/icon-span.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { icon, type IconName } from './icons';
|
||||
|
||||
type IconSpanProps = {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
// Safe: the SVG strings in icons.ts are hard-coded FontAwesome Pro paths bundled at
|
||||
// compile time. No user input flows through here — nothing to sanitize.
|
||||
export const IconSpan = ({ name, size = 16, color = 'currentColor', class: className }: IconSpanProps) => {
|
||||
const html = icon(name, size, color);
|
||||
return <span class={className} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
};
|
||||
@@ -1,27 +1,83 @@
|
||||
// FontAwesome Pro 6.7.2 Duotone SVGs — bundled as inline strings
|
||||
// FontAwesome Pro 7.1.0 Duotone SVGs — bundled as inline strings
|
||||
// License: https://fontawesome.com/license (Commercial License)
|
||||
// Paths use fill="currentColor" so color is inherited from the <svg> element.
|
||||
|
||||
export const icons = {
|
||||
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 28.7 28.7 0 64 0L448 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64l-138.7 0L185.6 508.8c-4.8 3.6-11.3 4.2-16.8 1.5s-8.8-8.2-8.8-14.3l0-80-96 0c-35.3 0-64-28.7-64-64L0 64zM96 208a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z"/><path class="fa-primary" d="M96 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
|
||||
// Navigation / UI
|
||||
'message-dots': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 128L0 352c0 53 43 96 96 96l32 0 0 72c0 13.3 10.7 24 24 24 5.2 0 10.2-1.7 14.4-4.8l115.2-86.4c4.2-3.1 9.2-4.8 14.4-4.8l120 0c53 0 96-43 96-96l0-224c0-53-43-96-96-96L96 32C43 32 0 75 0 128zM160 240a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zm128 0a32 32 0 1 1 -64 0 32 32 0 1 1 64 0zm128 0a32 32 0 1 1 -64 0 32 32 0 1 1 64 0z"/><path fill="currentColor" d="M96 240a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
|
||||
|
||||
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 192l448 0 0 272c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 192zM119 319c-9.4 9.4-9.4 24.6 0 33.9l64 64c4.7 4.7 10.8 7 17 7s12.3-2.3 17-7L329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0z"/><path class="fa-primary" d="M128 0C110.3 0 96 14.3 96 32l0 32L48 64C21.5 64 0 85.5 0 112l0 80 448 0 0-80c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32L160 64l0-32c0-17.7-14.3-32-32-32zM329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L329 305z"/></svg>`,
|
||||
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path opacity=".4" fill="currentColor" d="M0 160l448 0 0 272c0 26.5-21.5 48-48 48L48 480c-26.5 0-48-21.5-48-48L0 160z"/><path fill="currentColor" d="M160 32c0-17.7-14.3-32-32-32S96 14.3 96 32l0 32-48 0C21.5 64 0 85.5 0 112l0 48 448 0 0-48c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32-128 0 0-32z"/></svg>`,
|
||||
|
||||
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c1-3.5 1.4-7 1.4-10.5c0-15.8-9.4-30.6-24.6-36.9l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96C158.6 9.4 143.8 0 128 0c-3.5 0-7 .5-10.5 1.4l-88 24C12.1 30.2 0 46 0 64z"/><path class="fa-primary" d="M295 217c-9.4-9.4-9.4-24.6 0-33.9l135-135L384 48c-13.3 0-24-10.7-24-24s10.7-24 24-24L488 0c13.3 0 24 10.7 24 24l0 104c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-46.1L329 217c-9.4 9.4-24.6 9.4-33.9 0z"/></svg>`,
|
||||
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M86.8 90l19 47c5 12.3 19 18.2 31.2 13.3s18.2-19 13.3-31.2l-19-47c-5-12.3-19-18.2-31.2-13.3-12.3 5-18.2 19-13.3 31.2zm275 285c-5 12.3 1 26.3 13.3 31.2l47 19c12.3 5 26.3-1 31.2-13.3s-1-26.3-13.3-31.2l-47-19c-12.3-5-26.3 1-31.2 13.3z"/><path fill="currentColor" d="M112.1 1.4c19.7-5.4 40.3 4.7 48.1 23.5l40.5 97.3c6.9 16.5 2.1 35.6-11.8 47l-44.1 36.1c32.5 71.6 89 130 159.3 164.9L342.8 323c11.3-13.9 30.4-18.6 47-11.8L487 351.8c18.8 7.8 28.9 28.4 23.5 48.1l-1.5 5.5C491.4 470.1 428.9 525.3 352.6 509.2 177.6 472.1 39.9 334.4 2.8 159.4-13.3 83.1 41.9 20.6 106.5 2.9l5.5-1.5zM131.3 72c-5-12.3-19-18.2-31.2-13.3S81.8 77.7 86.8 90l19 47c5 12.3 19 18.2 31.2 13.3s18.2-19 13.3-31.2l-19-47zM393 361.7c-12.3-5-26.3 1-31.2 13.3s1 26.3 13.3 31.2l47 19c12.3 5 26.3-1 31.2-13.3s-1-26.3-13.3-31.2l-47-19z"/></svg>`,
|
||||
|
||||
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M1.4 72.3c0 6.1 1.4 12.4 4.7 18.6l70 134.6c63.3 7.9 126.6 15.8 190 23.7c3.4 .4 6 3.3 6 6.7s-2.6 6.3-6 6.7l-190 23.7L6.1 421.1c-14.6 28.1 7.3 58.6 35.2 58.6c5.3 0 10.8-1.1 16.3-3.5L492.9 285.3c11.6-5.1 19.1-16.6 19.1-29.3s-7.5-24.2-19.1-29.3L57.6 35.8C29.5 23.5 1.4 45.6 1.4 72.3z"/><path class="fa-primary" d="M76.1 286.5l190-23.7c3.4-.4 6-3.3 6-6.7s-2.6-6.3-6-6.7l-190-23.7 8.2 15.7c4.8 9.3 4.8 20.3 0 29.5l-8.2 15.7z"/></svg>`,
|
||||
'paper-plane-top': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M32 479c0 18.1 14.7 33 32.8 33 4.7 0 9.4-1 13.7-3L554.2 290c13.3-6.1 21.8-19.4 21.8-34l-448 0-93.2 209.6C33 469.8 32 474.4 32 479z"/><path fill="currentColor" d="M78.5 3L554.2 222c13.3 6.1 21.8 19.4 21.8 34L128 256 34.8 46.4C33 42.2 32 37.6 32 33 32 14.8 46.7 0 64.8 0 69.5 0 74.2 1 78.5 3z"/></svg>`,
|
||||
|
||||
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6z"/></svg>`,
|
||||
xmark: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/></svg>`,
|
||||
|
||||
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zm136 0c0-6.1 2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l47 47c37-37 74-74 111-111c4.7-4.7 10.8-7 17-7s12.3 2.3 17 7c2.3 2.3 4.1 5 5.3 7.9c.6 1.5 1 2.9 1.3 4.4c.2 1.1 .3 2.2 .3 2.2c.1 1.2 .1 1.2 .1 2.5c-.1 1.5-.1 1.9-.1 2.3c-.1 .7-.2 1.5-.3 2.2c-.3 1.5-.7 3-1.3 4.4c-1.2 2.9-2.9 5.6-5.3 7.9c-42.7 42.7-85.3 85.3-128 128c-4.7 4.7-10.8 7-17 7s-12.3-2.3-17-7c-21.3-21.3-42.7-42.7-64-64c-4.7-4.7-7-10.8-7-17z"/><path class="fa-primary" d="M369 175c9.4 9.4 9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0z"/></svg>`,
|
||||
'circle-check': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 256a256 256 0 1 0 512 0 256 256 0 1 0 -512 0zm135.1 7.1c9.4-9.4 24.6-9.4 33.9 0L221.1 315.2 340.5 151c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5L243.4 366.1c-4.1 5.7-10.5 9.3-17.5 9.8s-13.9-2-18.8-7l-72-72c-9.4-9.4-9.4-24.6 0-33.9z"/><path fill="currentColor" d="M340.5 151c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5L243.4 366.1c-4.1 5.7-10.5 9.3-17.5 9.8s-13.9-2-18.8-7l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0L221.1 315.2 340.5 151z"/></svg>`,
|
||||
|
||||
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M320 96c0 4.8 3 9.1 7.5 10.8L384 128l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 128l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 64 426.8 7.5C425.1 3 420.8 0 416 0s-9.1 3-10.8 7.5L384 64 327.5 85.2c-4.5 1.7-7.5 6-7.5 10.8zm0 320c0 4.8 3 9.1 7.5 10.8L384 448l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 448l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 384l-21.2-56.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L384 384l-56.5 21.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path class="fa-primary" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5l114.1 52.7L176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
|
||||
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M352 448c0 4.8 3 9.1 7.5 10.8L416 480 437.2 536.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L480 480 536.5 458.8c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L480 416 458.8 359.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L416 416 359.5 437.2c-4.5 1.7-7.5 6-7.5 10.8zM384 64c0 4.8 3 9.1 7.5 10.8L448 96 469.2 152.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L512 96 568.5 74.8c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L512 32 490.8-24.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L448 32 391.5 53.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path fill="currentColor" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5L123.4 321.8 176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
|
||||
|
||||
'hands-praying': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path opacity=".4" fill="currentColor" d="M352 224l0 119.6c0 57.2 37.9 107.4 92.8 123.1l154.4 44.1c9.7 2.8 20 .8 28.1-5.2S640 490 640 480l0-96c0-13.8-8.8-26-21.9-30.4l-58.1-19.4 0-110.7c0-29-9.3-57.3-26.5-80.7L440.2 16.3C427.1-1.5 402.1-5.3 384.3 7.8s-21.6 38.1-8.5 55.9L464 183.4 464 296c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-72c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/><path fill="currentColor" d="M200 320c13.3 0 24-10.7 24-24l0-72c0-17.7 14.3-32 32-32s32 14.3 32 32l0 119.6c0 57.2-37.9 107.4-92.8 123.1L40.8 510.8c-9.7 2.8-20 .8-28.1-5.2S0 490 0 480l0-96c0-13.8 8.8-26 21.9-30.4L80 334.3 80 223.6c0-29 9.3-57.3 26.5-80.7L199.8 16.3c13.1-17.8 38.1-21.6 55.9-8.5s21.6 38.1 8.5 55.9L176 183.4 176 296c0 13.3 10.7 24 24 24z"/></svg>`,
|
||||
|
||||
'hand-wave': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M73.4 265.4c-12.5 12.5-12.5 32.8 0 45.3 8.8 8.8 55.2 55.2 139.1 139.1l4.9 4.9c22.2 22.2 49.2 36.9 77.6 44.1 58 17 122.8 6.6 173.6-32.7 47.6-36.8 75.5-93.5 75.5-153.7L544 136c0-22.1-17.9-40-40-40s-40 17.9-40 40l0 77.7c0 4.7-6 7-9.4 3.7l-192-192c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L344 197.3c5.2 5.2 5.2 13.6 0 18.7s-13.6 5.2-18.7 0L182.6 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L280 261.3c5.2 5.2 5.2 13.6 0 18.7s-13.6 5.2-18.7 0L134.6 153.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L216 325.3c5.2 5.2 5.2 13.6 0 18.7s-13.6 5.2-18.7 0c-33.5-33.5-59.8-59.8-78.6-78.6-12.5-12.5-32.8-12.5-45.3 0z"/><path fill="currentColor" d="M392.2 67.4c1.9 13.1 14 22.2 27.2 20.4s22.2-14 20.4-27.2l-1.2-8.5c-5.5-38.7-36-69.1-74.7-74.7l-8.5-1.2c-13.1-1.9-25.3 7.2-27.2 20.4s7.2 25.3 20.4 27.2l8.5 1.2c17.6 2.5 31.4 16.3 33.9 33.9l1.2 8.5zM55.8 380.6c-1.9-13.1-14-22.2-27.2-20.4s-22.2 14-20.4 27.2l1.2 8.5c5.5 38.7 36 69.1 74.7 74.7l8.5 1.2c13.1 1.9 25.3-7.2 27.2-20.4s-7.2-25.3-20.4-27.2L90.9 423c-17.6-2.5-31.4-16.3-33.9-33.9l-1.2-8.5z"/></svg>`,
|
||||
|
||||
'shield-check': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M16 140c.5 99.2 41.3 280.7 213.6 363.2 16.7 8 36.1 8 52.7 0 172.4-82.5 213.2-263.9 213.7-363.2 .1-26.2-16.3-47.9-38.3-57.2L269.4 2.9C265.3 1 260.7 0 256.1 0s-9.2 1-13.4 2.9L54.3 82.8c-22 9.3-38.4 31-38.3 57.2zM166.8 293.5c-9.2-9.5-9-24.7 .6-33.9 9.5-9.2 24.7-9 33.9 .6 8.8 9.1 17.7 18.3 26.5 27.4 28.5-39.2 57.1-78.5 85.6-117.7 7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5c-34.1 46.9-68.3 93.9-102.4 140.8-4.2 5.7-10.7 9.4-17.8 9.8s-14-2.2-18.9-7.3c-15.5-16-30.9-32-46.4-48z"/><path fill="currentColor" d="M313.4 169.9c7.8-10.7 22.8-13.1 33.5-5.3s13.1 22.8 5.3 33.5L249.8 338.9c-4.2 5.7-10.7 9.4-17.8 9.8s-14-2.2-18.9-7.3l-46.4-48c-9.2-9.5-9-24.7 .6-33.9s24.7-8.9 33.9 .6l26.5 27.4 85.6-117.7z"/></svg>`,
|
||||
|
||||
'arrow-left': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M77.3 256l32 32 370.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0-32 32z"/><path fill="currentColor" d="M9.4 278.6c-12.5-12.5-12.5-32.8 0-45.3l160-160c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L77.3 256 214.6 393.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-160-160z"/></svg>`,
|
||||
|
||||
'arrow-right': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 256c0 17.7 14.3 32 32 32l370.7 0 32-32-32-32-370.7 0c-17.7 0-32 14.3-32 32z"/><path fill="currentColor" d="M502.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 256 297.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg>`,
|
||||
|
||||
'up-right-and-down-left-from-center': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 344L0 488c0 13.3 10.7 24 24 24l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-39-39 87-87c9.4-9.4 9.4-24.6 0-33.9l-32-32c-9.4-9.4-24.6-9.4-33.9 0l-87 87-39-39c-6.9-6.9-17.2-8.9-26.2-5.2S0 334.3 0 344z"/><path fill="currentColor" d="M488 0L344 0c-9.7 0-18.5 5.8-22.2 14.8S320.2 34.1 327 41l39 39-87 87c-9.4 9.4-9.4 24.6 0 33.9l32 32c9.4 9.4 24.6 9.4 33.9 0l87-87 39 39c6.9 6.9 17.2 8.9 26.2 5.2S512 177.7 512 168l0-144c0-13.3-10.7-24-24-24z"/></svg>`,
|
||||
|
||||
'down-left-and-up-right-to-center': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M7.5 439c-9.4 9.4-9.4 24.6 0 33.9l32 32c9.4 9.4 24.6 9.4 33.9 0l87-87 39 39c6.9 6.9 17.2 8.9 26.2 5.2s14.8-12.5 14.8-22.2l0-144c0-13.3-10.7-24-24-24l-144 0c-9.7 0-18.5 5.8-22.2 14.8s-1.7 19.3 5.2 26.2l39 39-87 87z"/><path fill="currentColor" d="M473.5 7c-9.4-9.4-24.6-9.4-33.9 0l-87 87-39-39c-6.9-6.9-17.2-8.9-26.2-5.2S272.5 62.3 272.5 72l0 144c0 13.3 10.7 24 24 24l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-39-39 87-87c9.4-9.4 9.4-24.6 0-33.9l-32-32z"/></svg>`,
|
||||
|
||||
// Branch / location
|
||||
hospital: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M0 192L0 448c0 35.3 28.7 64 64 64l176 0 0-112c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 112 176 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64l-64 0 0-64c0-35.3-28.7-64-64-64L192 0c-35.3 0-64 28.7-64 64l0 64-64 0c-35.3 0-64 28.7-64 64zm64 16c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm0 128c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM216 152c0-8.8 7.2-16 16-16l32 0 0-32c0-8.8 7.2-16 16-16l16 0c8.8 0 16 7.2 16 16l0 32 32 0c8.8 0 16 7.2 16 16l0 16c0 8.8-7.2 16-16 16l-32 0 0 32c0 8.8-7.2 16-16 16l-16 0c-8.8 0-16-7.2-16-16l0-32-32 0c-8.8 0-16-7.2-16-16l0-16zm232 56c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm0 128c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32z"/><path fill="currentColor" d="M264 104c0-8.8 7.2-16 16-16l16 0c8.8 0 16 7.2 16 16l0 32 32 0c8.8 0 16 7.2 16 16l0 16c0 8.8-7.2 16-16 16l-32 0 0 32c0 8.8-7.2 16-16 16l-16 0c-8.8 0-16-7.2-16-16l0-32-32 0c-8.8 0-16-7.2-16-16l0-16c0-8.8 7.2-16 16-16l32 0 0-32zM112 256l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16zm16 112c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32zm112 32c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 112-96 0 0-112zm272-32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32zM496 256l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16z"/></svg>`,
|
||||
|
||||
'location-dot': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M0 188.6c0 119.3 120.2 262.3 170.4 316.8 11.8 12.8 31.5 12.8 43.3 0 50.2-54.5 170.4-197.5 170.4-316.8 0-104.1-86-188.6-192-188.6S0 84.4 0 188.6zM256 192a64 64 0 1 1 -128 0 64 64 0 1 1 128 0z"/><path fill="currentColor" d="M128 192a64 64 0 1 1 128 0 64 64 0 1 1 -64 0z"/></svg>`,
|
||||
|
||||
// Medical departments
|
||||
stethoscope: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M160 348.8c10.3 2.1 21 3.2 32 3.2s21.7-1.1 32-3.2l0 19.2c0 61.9 50.1 112 112 112s112-50.1 112-112l0-85.5c10 3.5 20.8 5.5 32 5.5s22-1.9 32-5.5l0 85.5c0 97.2-78.8 176-176 176S160 465.2 160 368l0-19.2z"/><path fill="currentColor" d="M80 0C53.5 0 32 21.5 32 48l0 144c0 88.4 71.6 160 160 160s160-71.6 160-160l0-144c0-26.5-21.5-48-48-48L256 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l32 0 0 128c0 53-43 96-96 96s-96-43-96-96l0-128 32 0c17.7 0 32-14.3 32-32S145.7 0 128 0L80 0zM448 192a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a96 96 0 1 0 -192 0 96 96 0 1 0 192 0z"/></svg>`,
|
||||
|
||||
'heart-pulse': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M0 165.1l0 2.6c0 23.6 6.2 48 16.6 72.3l106 0c3.2 0 6.1-1.9 7.4-4.9l31.8-76.3c3.7-8.8 12.3-14.6 21.8-14.8s18.3 5.4 22.2 14.1l51.3 113.9 41.4-82.8c4.1-8.1 12.4-13.3 21.5-13.3s17.4 5.1 21.5 13.3l23.2 46.3c1.4 2.7 4.1 4.4 7.2 4.4l123.6 0c10.5-24.3 16.6-48.7 16.6-72.3l0-2.6C512 91.6 452.4 32 378.9 32 336.2 32 296 52.5 271 87.1l-15 20.7-15-20.7C216 52.5 175.9 32 133.1 32 59.6 32 0 91.6 0 165.1zM42.5 288c47.2 73.8 123 141.7 170.4 177.9 12.4 9.4 27.6 14.1 43.1 14.1s30.8-4.6 43.1-14.1C346.6 429.7 422.4 361.8 469.6 288l-97.8 0c-21.2 0-40.6-12-50.1-31l-1.7-3.4-42.5 85.1c-4.1 8.3-12.7 13.5-22 13.3s-17.6-5.7-21.4-14.1l-49.3-109.5-10.5 25.2c-8.7 20.9-29.1 34.5-51.7 34.5l-80.2 0z"/><path fill="currentColor" d="M42.5 288c-10.1-15.8-18.9-31.9-25.8-48l106 0c3.2 0 6.1-1.9 7.4-4.9l31.8-76.3c3.7-8.8 12.3-14.6 21.8-14.8s18.3 5.4 22.2 14.1l51.3 113.9 41.4-82.8c4.1-8.1 12.4-13.3 21.5-13.3s17.4 5.1 21.5 13.3l23.2 46.3c1.4 2.7 4.1 4.4 7.2 4.4l123.6 0c-6.9 16.1-15.7 32.2-25.8 48l-97.8 0c-21.2 0-40.6-12-50.1-31l-1.7-3.4-42.5 85.1c-4.1 8.3-12.7 13.5-22 13.3s-17.6-5.7-21.4-14.1l-49.3-109.5-10.5 25.2c-8.7 20.9-29.1 34.5-51.7 34.5l-80.2 0z"/></svg>`,
|
||||
|
||||
bone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M197.4 160c-3.9 0-7.2-2.8-8.1-6.6-10.2-42.1-48.1-73.4-93.3-73.4-53 0-96 43-96 96 0 29.1 12.9 55.1 33.3 72.7 4.3 3.7 4.3 10.8 0 14.5-20.4 17.6-33.3 43.7-33.3 72.7 0 53 43 96 96 96 45.2 0 83.1-31.3 93.3-73.4 .9-3.8 4.2-6.6 8.1-6.6l245.1 0c3.9 0 7.2 2.8 8.1 6.6 10.2 42.1 48.1 73.4 93.3 73.4 53 0 96-43 96-96 0-29.1-12.9-55.1-33.3-72.7-4.3-3.7-4.3-10.8 0-14.5 20.4-17.6 33.3-43.7 33.3-72.7 0-53-43-96-96-96-45.2 0-83.1 31.3-93.3 73.4-.9 3.8-4.2 6.6-8.1 6.6l-245.1 0z"/></svg>`,
|
||||
|
||||
'person-pregnant': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M136 24a56 56 0 1 0 112 0 56 56 0 1 0 -112 0z"/><path fill="currentColor" d="M74.6 305.8l29-43.5-30.5 113.5c-2.6 9.6-.6 19.9 5.5 27.8S94 416 104 416l8 0 0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96 32 0 0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-110.8c8.6-4.5 16.8-10 24.3-16.5l4-3.4c22.6-19.4 35.7-47.7 35.7-77.6 0-35.9-18.8-69.1-49.6-87.6l-30.4-18.2 0-1.8c0-46.5-37.7-84.1-84.1-84.1-28.1 0-54.4 14.1-70 37.5L21.4 270.2c-9.8 14.7-5.8 34.6 8.9 44.4s34.6 5.8 44.4-8.9z"/></svg>`,
|
||||
|
||||
ear: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M0 192L0 384c0 70.7 57.3 128 128 128l9.3 0c52.3 0 99.4-31.9 118.8-80.5l20.1-50.2c5.5-13.7 15.8-24.8 27.8-33.4 48.4-34.9 80-91.7 80-156 0-106-86-192-192-192S0 86 0 192zm64 0c0-70.7 57.3-128 128-128s128 57.3 128 128l0 8c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-8c0-44.2-35.8-80-80-80s-80 35.8-80 80l0 16.4c36 4 64 34.5 64 71.6 0 39.8-32.2 72-72 72l-16 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l16 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-16 0c-13.3 0-24-10.7-24-24l0-40z"/><path fill="currentColor" d="M192 112c-44.2 0-80 35.8-80 80l0 16.4c36 4 64 34.5 64 71.6 0 39.8-32.2 72-72 72l-16 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l16 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-16 0c-13.3 0-24-10.7-24-24l0-40c0-70.7 57.3-128 128-128s128 57.3 128 128l0 8c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-8c0-44.2-35.8-80-80-80z"/></svg>`,
|
||||
|
||||
baby: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path opacity=".4" fill="currentColor" d="M7.7 144.5c13-17.9 38-21.8 55.9-8.8L99.8 162c26.8 19.5 59.1 30 92.2 30s65.4-10.5 92.2-30l36.2-26.4c17.9-13 42.9-9 55.9 8.8s9 42.9-8.8 55.9l-36.2 26.4c-13.6 9.9-28.1 18.2-43.3 25l0 36.3-192 0 0-36.3c-15.2-6.7-29.7-15.1-43.3-25L16.5 200.3c-17.9-13-21.8-38-8.8-55.9zM47.2 401.1l50.2-71.8c20.2 17.7 40.4 35.3 60.6 53l-26 37.2 24.3 24.3c15.6 15.6 15.6 40.9 0 56.6s-40.9 15.6-56.6 0l-48-48C38 438.6 36.1 417 47.2 401.1zM264 88a72 72 0 1 1 -144 0 72 72 0 1 1 144 0zM226 382.3c20.2-17.7 40.4-35.3 60.6-53l50.2 71.8c11.1 15.9 9.2 37.5-4.5 51.2l-48 48c-15.6 15.6-40.9 15.6-56.6 0s-15.6-40.9 0-56.6l24.3-24.3-26-37.2z"/><path fill="currentColor" d="M160 384l-64-56 0-40 192 0 0 40-64 56-64 0z"/></svg>`,
|
||||
|
||||
brain: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path opacity=".4" fill="currentColor" d="M8 272c0 26.2 12.6 49.4 32 64-10 13.4-16 30-16 48 0 44.2 35.8 80 80 80 .7 0 1.3 0 2 0 7.1 27.6 32.2 48 62 48l32 0c17.7 0 32-14.3 32-32l0-448c0-17.7-14.3-32-32-32L176 0c-30.9 0-56 25.1-56 56l0 24c-44.2 0-80 35.8-80 80 0 15 4.1 29 11.2 40.9-25.7 13.3-43.2 40.1-43.2 71.1zM280 32l0 448c0 17.7 14.3 32 32 32l32 0c29.8 0 54.9-20.4 62-48 .7 0 1.3 0 2 0 44.2 0 80-35.8 80-80 0-18-6-34.6-16-48 19.4-14.6 32-37.8 32-64 0-30.9-17.6-57.8-43.2-71.1 7.1-12 11.2-26 11.2-40.9 0-44.2-35.8-80-80-80l0-24c0-30.9-25.1-56-56-56L312 0c-17.7 0-32 14.3-32 32z"/><path fill="currentColor" d="M232 32l48 0 0 448-48 0 0-448z"/></svg>`,
|
||||
|
||||
eye: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path opacity=".4" fill="currentColor" d="M2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6 14.9 35.7 46.2 87.7 93 131.1 47.1 43.7 111.8 80.6 192.6 80.6s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1 3.3-7.9 3.3-16.7 0-24.6-14.9-35.7-46.2-87.7-93-131.1-47.1-43.7-111.8-80.6-192.6-80.6S142.5 68.8 95.4 112.6C48.6 156 17.3 208 2.5 243.7zM432 256a144 144 0 1 1 -288 0 144 144 0 1 1 288 0z"/><path fill="currentColor" d="M288 192c0 35.3-28.7 64-64 64-11.5 0-22.3-3-31.7-8.4-1 10.9-.1 22.1 2.9 33.2 13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-12.2-45.7-55.5-74.8-101.1-70.8 5.3 9.3 8.4 20.1 8.4 31.7z"/></svg>`,
|
||||
|
||||
tooth: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M145 5.7L224 32 303 5.7C314.3 1.9 326 0 337.9 0 398.7 0 448 49.3 448 110.1l0 68.5c0 29.4-9.5 58.1-27.2 81.6l-1.1 1.5c-12.9 17.2-21.3 37.4-24.3 58.7L373.7 471.9c-3.3 23-23 40.1-46.2 40.1-22.8 0-42.3-16.5-46-39L261.3 351.6c-3-18.2-18.8-31.6-37.3-31.6s-34.2 13.4-37.3 31.6L166.5 473c-3.8 22.5-23.2 39-46 39-23.2 0-42.9-17.1-46.2-40.1L52.6 320.5c-3-21.3-11.4-41.5-24.3-58.7l-1.1-1.5C9.5 236.7 0 208.1 0 178.7l0-68.5C0 49.3 49.3 0 110.1 0 122 0 133.7 1.9 145 5.7z"/></svg>`,
|
||||
};
|
||||
|
||||
// Render an icon as an HTML string with given size and color
|
||||
export const icon = (name: keyof typeof icons, size = 16, color = 'currentColor'): string => {
|
||||
const svg = icons[name];
|
||||
return svg
|
||||
.replace('<svg', `<svg width="${size}" height="${size}" style="fill:${color};vertical-align:middle;"`)
|
||||
.replace(/\.fa-primary/g, '.p')
|
||||
.replace(/\.fa-secondary\{opacity:\.4\}/g, `.s{opacity:.4;fill:${color}}`);
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
// Render an icon as an HTML string with given size and color.
|
||||
// Color cascades to paths via fill="currentColor".
|
||||
export const icon = (name: IconName, size = 16, color = 'currentColor'): string => {
|
||||
return icons[name].replace(
|
||||
'<svg',
|
||||
`<svg width="${size}" height="${size}" style="vertical-align:middle;color:${color};flex-shrink:0"`,
|
||||
);
|
||||
};
|
||||
|
||||
// Map a department name to a medical icon. Keyword-based with stethoscope fallback.
|
||||
export const departmentIcon = (department: string): IconName => {
|
||||
const key = department.toLowerCase().replace(/_/g, ' ');
|
||||
if (key.includes('cardio') || key.includes('heart')) return 'heart-pulse';
|
||||
if (key.includes('ortho') || key.includes('bone') || key.includes('spine')) return 'bone';
|
||||
if (key.includes('gyn') || key.includes('obstet') || key.includes('maternity') || key.includes('pregnan')) return 'person-pregnant';
|
||||
if (key.includes('ent') || key.includes('otolaryn') || key.includes('ear') || key.includes('nose') || key.includes('throat')) return 'ear';
|
||||
if (key.includes('pediatric') || key.includes('paediatric') || key.includes('child') || key.includes('neonat')) return 'baby';
|
||||
if (key.includes('neuro') || key.includes('psych') || key.includes('mental')) return 'brain';
|
||||
if (key.includes('ophthal') || key.includes('eye') || key.includes('vision') || key.includes('retina')) return 'eye';
|
||||
if (key.includes('dental') || key.includes('dent') || key.includes('tooth')) return 'tooth';
|
||||
return 'stethoscope';
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render } from 'preact';
|
||||
import { initApi, fetchInit } from './api';
|
||||
import { loadTurnstile } from './captcha';
|
||||
import { Widget } from './widget';
|
||||
import type { WidgetConfig } from './types';
|
||||
|
||||
@@ -20,10 +21,19 @@ const init = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preload Turnstile script so the captcha gate renders quickly on first open.
|
||||
// No-op if siteKey is empty (dev mode — backend fails open).
|
||||
if (config.captchaSiteKey) {
|
||||
loadTurnstile().catch(() => {
|
||||
console.warn('[HelixWidget] Turnstile preload failed — gate will retry on open');
|
||||
});
|
||||
}
|
||||
|
||||
// Create shadow DOM host
|
||||
// No font-family here — we want the widget to inherit from the host page.
|
||||
const host = document.createElement('div');
|
||||
host.id = 'helix-widget-host';
|
||||
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;';
|
||||
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;';
|
||||
document.body.appendChild(host);
|
||||
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
|
||||
142
widget-src/src/store.tsx
Normal file
142
widget-src/src/store.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createContext } from 'preact';
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { fetchDoctors } from './api';
|
||||
import type { BookingPrefill, Doctor } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Centralized widget state.
|
||||
//
|
||||
// One source of truth for everything that's shared across the three tabs
|
||||
// (chat, book, contact): visitor identity, active lead, captcha token,
|
||||
// booking prefill handoff, and the doctors roster. Tab-local state (wizard
|
||||
// steps, message lists, form drafts for tab-specific fields) stays in its
|
||||
// own component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Visitor = { name: string; phone: string };
|
||||
|
||||
type WidgetStore = {
|
||||
// Visitor identity — populated by whichever form the user fills first.
|
||||
// Name/phone inputs in every tab bind directly to this.
|
||||
visitor: Visitor;
|
||||
updateVisitor: (patch: Partial<Visitor>) => void;
|
||||
|
||||
// Lead lifecycle. Set after the first backend call that commits the
|
||||
// visitor (chat-start, book, or contact — chat-start is the common case).
|
||||
// When non-null, the chat pre-chat gate auto-skips.
|
||||
leadId: string | null;
|
||||
setLeadId: (id: string | null) => void;
|
||||
|
||||
// Cloudflare Turnstile token from the window-level gate. Consumed by
|
||||
// booking + contact submit flows (chat uses WidgetKeyGuard only).
|
||||
captchaToken: string;
|
||||
setCaptchaToken: (t: string) => void;
|
||||
|
||||
// Transient handoff: when the user picks a slot in the chat widget, this
|
||||
// carries the doctor/date/time into the Book tab so it lands on the
|
||||
// details form with everything preselected. Booking clears it after consuming.
|
||||
bookingPrefill: BookingPrefill | null;
|
||||
setBookingPrefill: (p: BookingPrefill | null) => void;
|
||||
|
||||
// Doctors roster — fetched once when the provider mounts. Replaces the
|
||||
// per-component fetch that used to live in booking.tsx.
|
||||
doctors: Doctor[];
|
||||
doctorsLoading: boolean;
|
||||
doctorsError: string;
|
||||
|
||||
// Unique branch names derived from the doctors roster (via doctor.clinic.clinicName).
|
||||
branches: string[];
|
||||
|
||||
// Currently selected branch. Session-only, shared across chat + book.
|
||||
// Auto-set on mount if exactly one branch exists. Cleared on session end.
|
||||
selectedBranch: string | null;
|
||||
setSelectedBranch: (branch: string | null) => void;
|
||||
};
|
||||
|
||||
const WidgetStoreContext = createContext<WidgetStore | null>(null);
|
||||
|
||||
type ProviderProps = { children: ComponentChildren };
|
||||
|
||||
export const WidgetStoreProvider = ({ children }: ProviderProps) => {
|
||||
const [visitor, setVisitor] = useState<Visitor>({ name: '', phone: '' });
|
||||
const [leadId, setLeadId] = useState<string | null>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [bookingPrefill, setBookingPrefill] = useState<BookingPrefill | null>(null);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [doctorsLoading, setDoctorsLoading] = useState(false);
|
||||
const [doctorsError, setDoctorsError] = useState('');
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
|
||||
const updateVisitor = useCallback((patch: Partial<Visitor>) => {
|
||||
setVisitor(prev => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
// Unique branches derived from the doctors roster. Memoized so stable
|
||||
// references flow down to components that depend on it.
|
||||
const branches = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const d of doctors) {
|
||||
const name = d.clinic?.clinicName?.trim();
|
||||
if (name) set.add(name);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [doctors]);
|
||||
|
||||
// Single-branch hospitals get silent auto-select. Multi-branch ones leave
|
||||
// selectedBranch null and rely on the chat/booking flows to prompt.
|
||||
useEffect(() => {
|
||||
if (selectedBranch) return;
|
||||
if (branches.length === 1) setSelectedBranch(branches[0]);
|
||||
}, [branches, selectedBranch]);
|
||||
|
||||
// Fetch the doctors roster once on mount. We intentionally don't refetch
|
||||
// unless the widget is fully reloaded — the roster doesn't change during
|
||||
// a single visitor session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDoctorsLoading(true);
|
||||
setDoctorsError('');
|
||||
fetchDoctors()
|
||||
.then(docs => {
|
||||
if (cancelled) return;
|
||||
setDoctors(docs);
|
||||
setDoctorsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setDoctorsError('Failed to load doctors');
|
||||
setDoctorsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const store: WidgetStore = {
|
||||
visitor,
|
||||
updateVisitor,
|
||||
leadId,
|
||||
setLeadId,
|
||||
captchaToken,
|
||||
setCaptchaToken,
|
||||
bookingPrefill,
|
||||
setBookingPrefill,
|
||||
doctors,
|
||||
doctorsLoading,
|
||||
doctorsError,
|
||||
branches,
|
||||
selectedBranch,
|
||||
setSelectedBranch,
|
||||
};
|
||||
|
||||
return <WidgetStoreContext.Provider value={store}>{children}</WidgetStoreContext.Provider>;
|
||||
};
|
||||
|
||||
export const useWidgetStore = (): WidgetStore => {
|
||||
const store = useContext(WidgetStoreContext);
|
||||
if (!store) {
|
||||
throw new Error('useWidgetStore must be used inside a WidgetStoreProvider');
|
||||
}
|
||||
return store;
|
||||
};
|
||||
@@ -1,20 +1,34 @@
|
||||
import type { WidgetConfig } from './types';
|
||||
|
||||
export const getStyles = (config: WidgetConfig) => `
|
||||
:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
/* all: initial isolates the widget from host-page style bleed, but we then
|
||||
explicitly re-enable font-family inheritance so the widget picks up the
|
||||
host page's font stack instead of falling back to system default. */
|
||||
:host {
|
||||
all: initial;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
input, select, textarea, button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
|
||||
.widget-bubble {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
background: #fff; color: ${config.colors.primary};
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transition: transform 0.2s; border: none; outline: none;
|
||||
cursor: pointer; border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.15), 0 2px 4px rgba(17, 24, 39, 0.08);
|
||||
transition: transform 0.2s, box-shadow 0.2s; outline: none;
|
||||
}
|
||||
.widget-bubble:hover { transform: scale(1.08); }
|
||||
.widget-bubble img { width: 28px; height: 28px; border-radius: 6px; }
|
||||
.widget-bubble svg { width: 24px; height: 24px; fill: currentColor; }
|
||||
.widget-bubble:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 10px 28px rgba(17, 24, 39, 0.2), 0 4px 8px rgba(17, 24, 39, 0.1);
|
||||
}
|
||||
.widget-bubble img { width: 32px; height: 32px; border-radius: 6px; }
|
||||
.widget-bubble svg { width: 26px; height: 26px; }
|
||||
|
||||
.widget-panel {
|
||||
width: 380px; height: 520px; border-radius: 16px;
|
||||
@@ -22,25 +36,57 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0;
|
||||
animation: slideUp 0.25s ease-out;
|
||||
transition: width 0.25s ease, height 0.25s ease, border-radius 0.25s ease;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes widgetFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Maximized modal mode */
|
||||
.widget-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(17, 24, 39, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: widgetFadeIn 0.2s ease-out;
|
||||
z-index: 1;
|
||||
}
|
||||
.widget-panel-maximized {
|
||||
position: fixed;
|
||||
top: 50%; left: 50%; right: auto; bottom: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(960px, 92vw);
|
||||
height: min(720px, 88vh);
|
||||
max-width: 92vw; max-height: 88vh;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.25);
|
||||
z-index: 2;
|
||||
animation: widgetFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px; background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
.widget-header img { width: 32px; height: 32px; border-radius: 8px; }
|
||||
.widget-header-text { flex: 1; }
|
||||
.widget-header-text { flex: 1; min-width: 0; }
|
||||
.widget-header-name { font-size: 14px; font-weight: 600; }
|
||||
.widget-header-sub { font-size: 11px; opacity: 0.8; }
|
||||
.widget-close {
|
||||
background: none; border: none; color: #fff; cursor: pointer;
|
||||
font-size: 18px; padding: 4px; opacity: 0.8;
|
||||
.widget-header-sub { font-size: 11px; opacity: 0.85; }
|
||||
.widget-header-branch {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.widget-close:hover { opacity: 1; }
|
||||
.widget-header-btn {
|
||||
background: none; border: none; color: #fff; cursor: pointer;
|
||||
padding: 6px; opacity: 0.8; display: flex; align-items: center;
|
||||
justify-content: center; border-radius: 6px; margin-left: 2px;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.widget-header-btn:hover { opacity: 1; background: rgba(255,255,255,0.15); }
|
||||
|
||||
.widget-tabs {
|
||||
display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa;
|
||||
@@ -49,7 +95,8 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
flex: 1; padding: 10px 0; text-align: center; font-size: 12px;
|
||||
font-weight: 500; cursor: pointer; border: none; background: none;
|
||||
color: #6b7280; border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.15s; display: inline-flex; align-items: center;
|
||||
justify-content: center; gap: 6px;
|
||||
}
|
||||
.widget-tab.active {
|
||||
color: ${config.colors.primary}; border-bottom-color: ${config.colors.primary};
|
||||
@@ -57,6 +104,9 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
}
|
||||
|
||||
.widget-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.widget-panel-maximized .widget-body { padding: 24px 32px; }
|
||||
.widget-panel-maximized .widget-tabs { padding: 0 16px; }
|
||||
.widget-panel-maximized .widget-tab { padding: 14px 0; font-size: 13px; }
|
||||
|
||||
.widget-input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||
@@ -72,6 +122,19 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; }
|
||||
.widget-field { margin-bottom: 12px; }
|
||||
|
||||
.widget-section-title {
|
||||
font-size: 13px; font-weight: 600; color: #1f2937;
|
||||
margin-bottom: 10px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.widget-section-sub {
|
||||
font-size: 12px; color: #6b7280; margin-bottom: 16px;
|
||||
}
|
||||
.widget-error {
|
||||
color: #dc2626; font-size: 12px; margin-bottom: 8px;
|
||||
padding: 8px 10px; background: #fef2f2; border-radius: 6px;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.widget-btn {
|
||||
width: 100%; padding: 10px 16px; border: none; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
@@ -80,6 +143,40 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
.widget-btn:hover { opacity: 0.9; }
|
||||
.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.widget-btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
.widget-btn-with-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.widget-btn-row {
|
||||
display: flex; gap: 8px; margin-top: 12px;
|
||||
}
|
||||
.widget-btn-row > .widget-btn { flex: 1; }
|
||||
|
||||
/* Row buttons — department list, doctor list, etc. */
|
||||
.widget-row-btn {
|
||||
width: 100%; display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 14px; margin-bottom: 6px; border: 1px solid #e5e7eb;
|
||||
border-radius: 10px; background: #fff; cursor: pointer;
|
||||
text-align: left; color: #1f2937; transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.widget-row-btn:hover {
|
||||
border-color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
}
|
||||
.widget-row-btn.widget-row-btn-stack { align-items: flex-start; }
|
||||
.widget-row-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
background: ${config.colors.primaryLight}; color: ${config.colors.primary};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.widget-row-main { flex: 1; min-width: 0; }
|
||||
.widget-row-label { font-size: 13px; font-weight: 600; color: #1f2937; }
|
||||
.widget-row-sub { font-size: 11px; color: #6b7280; margin-top: 2px; }
|
||||
.widget-row-chevron {
|
||||
display: inline-flex; color: #9ca3af; flex-shrink: 0;
|
||||
}
|
||||
.widget-row-btn:hover .widget-row-chevron { color: ${config.colors.primary}; }
|
||||
|
||||
.widget-slots {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
|
||||
@@ -94,38 +191,232 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
.widget-slot.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
|
||||
|
||||
.widget-success {
|
||||
text-align: center; padding: 24px 16px;
|
||||
text-align: center; padding: 32px 16px;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.widget-success-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: #ecfdf5; margin-bottom: 16px;
|
||||
}
|
||||
.widget-success-icon { font-size: 40px; margin-bottom: 12px; }
|
||||
.widget-success-title { font-size: 16px; font-weight: 600; color: #059669; margin-bottom: 8px; }
|
||||
.widget-success-text { font-size: 13px; color: #6b7280; }
|
||||
.widget-success-text { font-size: 13px; color: #6b7280; line-height: 1.6; }
|
||||
|
||||
/* Chat empty state */
|
||||
.chat-empty {
|
||||
text-align: center; padding: 32px 8px 16px;
|
||||
}
|
||||
.chat-intro {
|
||||
padding: 24px 4px 8px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.chat-intro .chat-empty-icon { align-self: center; }
|
||||
.chat-intro .chat-empty-title { text-align: center; font-size: 15px; font-weight: 600; color: #1f2937; margin-bottom: 6px; }
|
||||
.chat-intro .chat-empty-text { text-align: center; font-size: 12px; color: #6b7280; margin-bottom: 20px; line-height: 1.5; }
|
||||
.chat-empty-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.chat-empty-title {
|
||||
font-size: 15px; font-weight: 600; color: #1f2937; margin-bottom: 6px;
|
||||
}
|
||||
.chat-empty-text {
|
||||
font-size: 12px; color: #6b7280; margin-bottom: 18px; line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||||
.chat-msg { margin-bottom: 10px; display: flex; }
|
||||
.chat-msg.user { justify-content: flex-end; }
|
||||
.chat-msg.assistant { justify-content: flex-start; }
|
||||
.chat-msg-stack {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
max-width: 85%;
|
||||
}
|
||||
.chat-msg.user .chat-msg-stack { align-items: flex-end; }
|
||||
.chat-msg.assistant .chat-msg-stack { align-items: flex-start; }
|
||||
.chat-bubble {
|
||||
max-width: 80%; padding: 10px 14px; border-radius: 12px;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
padding: 10px 14px; border-radius: 12px;
|
||||
font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.chat-msg.user .chat-bubble { background: ${config.colors.primary}; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.chat-msg.assistant .chat-bubble { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; }
|
||||
|
||||
/* Typing indicator (animated dots) */
|
||||
.chat-typing-dots {
|
||||
display: inline-flex; gap: 4px; align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.chat-typing-dots > span {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #9ca3af; display: inline-block;
|
||||
animation: chatDot 1.4s ease-in-out infinite both;
|
||||
}
|
||||
.chat-typing-dots > span:nth-child(2) { animation-delay: 0.16s; }
|
||||
.chat-typing-dots > span:nth-child(3) { animation-delay: 0.32s; }
|
||||
@keyframes chatDot {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }
|
||||
40% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Generic chat widget (tool UI) container */
|
||||
.chat-widget {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
||||
padding: 12px; font-size: 12px; color: #1f2937;
|
||||
width: 100%; max-width: 300px;
|
||||
}
|
||||
.chat-widget-title {
|
||||
font-size: 12px; font-weight: 600; color: #374151;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
}
|
||||
.chat-widget-loading {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; background: #f3f4f6; border-radius: 10px;
|
||||
font-size: 12px; color: #6b7280;
|
||||
}
|
||||
.chat-widget-loading-label { font-style: italic; }
|
||||
.chat-widget-empty { font-size: 12px; color: #6b7280; font-style: italic; }
|
||||
.chat-widget-error { font-size: 12px; color: #dc2626; padding: 8px 12px; background: #fef2f2; border-radius: 8px; border: 1px solid #fecaca; }
|
||||
|
||||
/* Branch picker cards */
|
||||
.chat-widget-branches .chat-widget-branch-card {
|
||||
width: 100%; display: block; text-align: left;
|
||||
padding: 10px 12px; margin-bottom: 6px;
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
cursor: pointer; font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-branches .chat-widget-branch-card:last-child { margin-bottom: 0; }
|
||||
.chat-widget-branches .chat-widget-branch-card:hover {
|
||||
border-color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
}
|
||||
.chat-widget-branch-name { font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; }
|
||||
.chat-widget-branch-meta { font-size: 11px; color: #6b7280; }
|
||||
|
||||
/* Department chip grid */
|
||||
.chat-widget-dept-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.chat-widget-dept-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; border-radius: 999px;
|
||||
border: 1px solid ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
color: ${config.colors.primary};
|
||||
font-size: 11px; font-weight: 500; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-dept-chip:hover {
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
|
||||
/* Doctor cards */
|
||||
.chat-widget-doctor-card {
|
||||
padding: 10px; background: #f9fafb; border-radius: 8px;
|
||||
border: 1px solid #f3f4f6; margin-bottom: 6px;
|
||||
}
|
||||
.chat-widget-doctor-card:last-child { margin-bottom: 0; }
|
||||
.chat-widget-doctor-name { font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; }
|
||||
.chat-widget-doctor-meta { font-size: 11px; color: #6b7280; line-height: 1.4; }
|
||||
.chat-widget-doctor-action {
|
||||
margin-top: 8px; width: 100%;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 6px 10px; font-size: 11px; font-weight: 600;
|
||||
color: ${config.colors.primary};
|
||||
background: #fff;
|
||||
border: 1px solid ${config.colors.primary};
|
||||
border-radius: 6px; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-doctor-action:hover {
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
|
||||
/* Clinic timings widget */
|
||||
.chat-widget-timings .chat-widget-timing-dept {
|
||||
margin-bottom: 10px; padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.chat-widget-timings .chat-widget-timing-dept:last-child {
|
||||
margin-bottom: 0; padding-bottom: 0; border-bottom: 0;
|
||||
}
|
||||
.chat-widget-timing-dept-name {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; font-weight: 600; color: ${config.colors.primary};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-widget-timing-row {
|
||||
padding: 4px 0 4px 22px;
|
||||
}
|
||||
.chat-widget-timing-doctor {
|
||||
font-size: 12px; font-weight: 500; color: #1f2937;
|
||||
}
|
||||
.chat-widget-timing-hours {
|
||||
font-size: 11px; color: #4b5563; line-height: 1.4;
|
||||
}
|
||||
.chat-widget-timing-clinic {
|
||||
font-size: 11px; color: #9ca3af; font-style: italic;
|
||||
}
|
||||
|
||||
/* Slots grid widget */
|
||||
.chat-widget-slots-doctor { font-size: 13px; font-weight: 600; color: #1f2937; }
|
||||
.chat-widget-slots-meta { font-size: 11px; color: #6b7280; margin-bottom: 8px; }
|
||||
.chat-widget-slots-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
|
||||
}
|
||||
.chat-widget-slot-btn {
|
||||
padding: 8px 6px; font-size: 12px; font-weight: 500;
|
||||
color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight};
|
||||
border: 1px solid ${config.colors.primary};
|
||||
border-radius: 6px; cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.chat-widget-slot-btn:hover {
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
}
|
||||
.chat-widget-slot-btn.unavailable {
|
||||
color: #9ca3af; background: #f3f4f6;
|
||||
border-color: #e5e7eb; cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Booking suggestion card */
|
||||
.chat-widget-booking {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: ${config.colors.primaryLight};
|
||||
border-color: ${config.colors.primary};
|
||||
}
|
||||
.chat-widget-booking-icon {
|
||||
flex-shrink: 0; width: 40px; height: 40px;
|
||||
border-radius: 10px; background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: ${config.colors.primary};
|
||||
}
|
||||
.chat-widget-booking-body { flex: 1; min-width: 0; }
|
||||
.chat-widget-booking-title { font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; }
|
||||
.chat-widget-booking-reason { font-size: 12px; color: #4b5563; line-height: 1.5; margin-bottom: 6px; }
|
||||
.chat-widget-booking-dept { font-size: 11px; color: ${config.colors.primary}; font-weight: 500; margin-bottom: 8px; }
|
||||
.chat-widget-booking .widget-btn { padding: 8px 14px; font-size: 12px; }
|
||||
|
||||
.chat-input-row { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
|
||||
.chat-input { flex: 1; }
|
||||
.chat-send {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
background: ${config.colors.primary}; color: #fff;
|
||||
border: none; cursor: pointer; display: flex;
|
||||
align-items: center; justify-content: center; font-size: 16px;
|
||||
align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-send:disabled { opacity: 0.5; }
|
||||
.chat-send:hover { opacity: 0.9; }
|
||||
.chat-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 12px; }
|
||||
.quick-action {
|
||||
padding: 6px 12px; border-radius: 16px; font-size: 11px;
|
||||
border: 1px solid ${config.colors.primary}; color: ${config.colors.primary};
|
||||
background: ${config.colors.primaryLight}; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.quick-action:hover { background: ${config.colors.primary}; color: #fff; }
|
||||
|
||||
@@ -135,4 +426,37 @@ export const getStyles = (config: WidgetConfig) => `
|
||||
}
|
||||
.widget-step.active { background: ${config.colors.primary}; }
|
||||
.widget-step.done { background: #059669; }
|
||||
|
||||
/* Captcha gate — full-panel verification screen */
|
||||
.widget-captcha-gate {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 32px 24px; text-align: center;
|
||||
background: #fafafa;
|
||||
}
|
||||
.widget-captcha-gate-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 96px; height: 96px; border-radius: 50%;
|
||||
background: ${config.colors.primaryLight}; margin-bottom: 20px;
|
||||
}
|
||||
.widget-captcha-gate-title {
|
||||
font-size: 17px; font-weight: 600; color: #1f2937; margin-bottom: 8px;
|
||||
}
|
||||
.widget-captcha-gate-text {
|
||||
font-size: 13px; color: #6b7280; margin-bottom: 24px; line-height: 1.5;
|
||||
max-width: 280px;
|
||||
}
|
||||
.widget-captcha {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 8px; width: 100%;
|
||||
}
|
||||
/* Placeholder reserves space for the Turnstile widget which is portaled to
|
||||
document.body (light DOM) and visually positioned over this element. */
|
||||
.widget-captcha-mount {
|
||||
width: 300px; height: 65px;
|
||||
display: block;
|
||||
}
|
||||
.widget-captcha-status {
|
||||
font-size: 11px; color: #6b7280; text-align: center;
|
||||
}
|
||||
.widget-captcha-error { color: #dc2626; }
|
||||
`;
|
||||
|
||||
@@ -20,7 +20,75 @@ export type TimeSlot = {
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
// A doctor card rendered inside a show_doctors tool result.
|
||||
export type ChatDoctor = {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string | null;
|
||||
visitingHours: string | null;
|
||||
clinic: string | null;
|
||||
};
|
||||
|
||||
export type ChatSlot = { time: string; available: boolean };
|
||||
|
||||
export type ClinicTimingDept = {
|
||||
name: string;
|
||||
entries: Array<{ name: string; hours: string; clinic: string | null }>;
|
||||
};
|
||||
|
||||
export type BranchOption = {
|
||||
name: string;
|
||||
doctorCount: number;
|
||||
departmentCount: number;
|
||||
};
|
||||
|
||||
// Per-tool output payload shapes (matching what the backend tool.execute returns).
|
||||
export type ToolOutputs = {
|
||||
pick_branch: { branches: BranchOption[] };
|
||||
list_departments: { branch: string | null; departments: string[] };
|
||||
show_clinic_timings: { branch: string | null; departments: ClinicTimingDept[] };
|
||||
show_doctors: { department: string; branch: string | null; doctors: ChatDoctor[] };
|
||||
show_doctor_slots: {
|
||||
doctor: { id: string; name: string; department: string | null; clinic: string | null } | null;
|
||||
date: string;
|
||||
slots: ChatSlot[];
|
||||
error?: string;
|
||||
};
|
||||
suggest_booking: { reason: string; department: string | null };
|
||||
};
|
||||
|
||||
// Seed data passed from chat → booking flow when a visitor picks a slot.
|
||||
export type BookingPrefill = {
|
||||
doctorId: string;
|
||||
date: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
export type ToolName = keyof ToolOutputs;
|
||||
|
||||
// UI parts are Vercel-style: an assistant message is a sequence of
|
||||
// TextPart | ToolPart that we render in order.
|
||||
export type ChatTextPart = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
state: 'streaming' | 'done';
|
||||
};
|
||||
|
||||
export type ChatToolPart = {
|
||||
type: 'tool';
|
||||
toolCallId: string;
|
||||
toolName: ToolName | string; // unknown tool names are rendered as fallback
|
||||
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
|
||||
input?: any;
|
||||
output?: any;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatPart = ChatTextPart | ChatToolPart;
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
parts: ChatPart[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { WidgetConfig } from './types';
|
||||
import { getStyles } from './styles';
|
||||
import { icon } from './icons';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { Captcha } from './captcha';
|
||||
import { Chat } from './chat';
|
||||
import { Booking } from './booking';
|
||||
import { Contact } from './contact';
|
||||
import { WidgetStoreProvider, useWidgetStore } from './store';
|
||||
|
||||
type Tab = 'chat' | 'book' | 'contact';
|
||||
|
||||
@@ -13,11 +15,9 @@ type WidgetProps = {
|
||||
shadow: ShadowRoot;
|
||||
};
|
||||
|
||||
// Outer wrapper — owns the shadow-DOM style injection and the store provider.
|
||||
// Everything inside WidgetShell reads shared state via useWidgetStore().
|
||||
export const Widget = ({ config, shadow }: WidgetProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('chat');
|
||||
|
||||
// Inject styles into shadow DOM
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = getStyles(config);
|
||||
@@ -25,53 +25,139 @@ export const Widget = ({ config, shadow }: WidgetProps) => {
|
||||
return () => { shadow.removeChild(style); };
|
||||
}, [config, shadow]);
|
||||
|
||||
return (
|
||||
<WidgetStoreProvider>
|
||||
<WidgetShell config={config} shadow={shadow} />
|
||||
</WidgetStoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetShell = ({ config, shadow }: WidgetProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('chat');
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const { captchaToken, setCaptchaToken, selectedBranch } = useWidgetStore();
|
||||
|
||||
// Maximized mode: host becomes a fullscreen fixed container so the modal
|
||||
// backdrop covers the page. Restored on exit.
|
||||
useEffect(() => {
|
||||
if (!maximized) return;
|
||||
const host = shadow.host as HTMLElement;
|
||||
const prevHostStyle = host.getAttribute('style');
|
||||
host.style.cssText = 'position:fixed;inset:0;z-index:999999;';
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
if (prevHostStyle !== null) host.setAttribute('style', prevHostStyle);
|
||||
else host.removeAttribute('style');
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [maximized, shadow]);
|
||||
|
||||
// Esc to exit maximized mode.
|
||||
useEffect(() => {
|
||||
if (!maximized) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMaximized(false); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [maximized]);
|
||||
|
||||
const requiresGate = Boolean(config.captchaSiteKey) && !captchaToken;
|
||||
|
||||
const close = () => {
|
||||
setMaximized(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Floating bubble */}
|
||||
{!open && (
|
||||
<button class="widget-bubble" onClick={() => setOpen(true)}>
|
||||
<button class="widget-bubble" onClick={() => setOpen(true)} aria-label="Open chat">
|
||||
{config.brand.logo ? (
|
||||
<img src={config.brand.logo} alt={config.brand.name} />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
|
||||
<IconSpan name="message-dots" size={26} color={config.colors.primary} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
{open && (
|
||||
<div class="widget-panel">
|
||||
<>
|
||||
{maximized && <div class="widget-backdrop" onClick={() => setMaximized(false)} />}
|
||||
<div class={`widget-panel ${maximized ? 'widget-panel-maximized' : ''}`}>
|
||||
{/* Header */}
|
||||
<div class="widget-header">
|
||||
{config.brand.logo && <img src={config.brand.logo} alt="" />}
|
||||
<div class="widget-header-text">
|
||||
<div class="widget-header-name">{config.brand.name}</div>
|
||||
<div class="widget-header-sub">We're here to help</div>
|
||||
<div class="widget-header-sub">
|
||||
We're here to help
|
||||
{selectedBranch && (
|
||||
<>
|
||||
{' • '}
|
||||
<span class="widget-header-branch">
|
||||
<IconSpan name="location-dot" size={10} color="#fff" />
|
||||
{selectedBranch}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button class="widget-close" onClick={() => setOpen(false)}>✕</button>
|
||||
{/* Icons bundled from FontAwesome Pro SVGs — static, not user input */}
|
||||
</div>
|
||||
<button
|
||||
class="widget-header-btn"
|
||||
onClick={() => setMaximized(m => !m)}
|
||||
aria-label={maximized ? 'Restore' : 'Maximize'}
|
||||
title={maximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
<IconSpan
|
||||
name={maximized ? 'down-left-and-up-right-to-center' : 'up-right-and-down-left-from-center'}
|
||||
size={14}
|
||||
color="#fff"
|
||||
/>
|
||||
</button>
|
||||
<button class="widget-header-btn" onClick={close} aria-label="Close" title="Close">
|
||||
<IconSpan name="xmark" size={16} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requiresGate ? (
|
||||
<div class="widget-captcha-gate">
|
||||
<div class="widget-captcha-gate-icon">
|
||||
<IconSpan name="shield-check" size={56} color={config.colors.primary} />
|
||||
</div>
|
||||
<div class="widget-captcha-gate-title">Quick security check</div>
|
||||
<div class="widget-captcha-gate-text">
|
||||
Please verify you're not a bot to continue.
|
||||
</div>
|
||||
<Captcha siteKey={config.captchaSiteKey} onToken={setCaptchaToken} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div class="widget-tabs">
|
||||
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
|
||||
<span innerHTML={icon('chat', 14)} /> Chat
|
||||
<IconSpan name="message-dots" size={14} /> Chat
|
||||
</button>
|
||||
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
|
||||
<span innerHTML={icon('calendar', 14)} /> Book
|
||||
<IconSpan name="calendar" size={14} /> Book
|
||||
</button>
|
||||
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
|
||||
<span innerHTML={icon('phone', 14)} /> Contact
|
||||
<IconSpan name="phone" size={14} /> Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div class="widget-body">
|
||||
{tab === 'chat' && <Chat />}
|
||||
{tab === 'chat' && <Chat onRequestBooking={() => setTab('book')} />}
|
||||
{tab === 'book' && <Booking />}
|
||||
{tab === 'contact' && <Contact />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user