mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
12 Commits
9ee087b898
...
hardening/
| Author | SHA1 | Date | |
|---|---|---|---|
| a837c95d8c | |||
| ac76ef5487 | |||
| 99954c1ff2 | |||
| 4b84792619 | |||
| 9890559ec1 | |||
| 9cb4d1c122 | |||
| 014b27cf90 | |||
| 826ced1e62 | |||
| bbea12185d | |||
| f1c026cf7a | |||
| d819888351 | |||
| 300fff25c1 |
203
.claude/skills/generate-whatsapp-flow.md
Normal file
203
.claude/skills/generate-whatsapp-flow.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Generate WhatsApp Flow
|
||||||
|
|
||||||
|
Generate a config-driven WhatsApp conversation flow JSON for the Helix Engage flow runtime engine.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
When the user asks to create a new WhatsApp flow, chatbot flow, or conversation automation — e.g., "create a WhatsApp flow for prescription refills", "build a feedback collection flow", "add a lab report flow".
|
||||||
|
|
||||||
|
## Flow Runtime Architecture
|
||||||
|
|
||||||
|
The flow engine reads JSON flow definitions from `src/messaging/flow/default-flows/` and executes them at runtime. Each flow is a graph of **Groups** (containers) containing **Blocks** (steps), connected by **Edges**.
|
||||||
|
|
||||||
|
### Execution Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Inbound WhatsApp message → match flow by trigger → create/resume session
|
||||||
|
→ walk forward through Groups → Blocks:
|
||||||
|
MessageBlock → send text/buttons/list to patient
|
||||||
|
InputBlock → PAUSE, wait for next message
|
||||||
|
ConditionBlock → evaluate variable, follow matching edge
|
||||||
|
SetVariableBlock → assign/transform variable
|
||||||
|
ToolCallBlock → call registered tool
|
||||||
|
AIBlock → generate LLM response
|
||||||
|
JumpBlock → jump to another group
|
||||||
|
→ End of group → follow outgoing edge → next group
|
||||||
|
→ No more edges → flow complete, session cleared
|
||||||
|
```
|
||||||
|
|
||||||
|
Session state stored in Redis with 24h TTL. Per-phone execution lock prevents concurrent flows.
|
||||||
|
|
||||||
|
### Flow JSON Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Flow = {
|
||||||
|
id: string; // "flow-{kebab-name}"
|
||||||
|
name: string; // Human-readable name
|
||||||
|
description: string; // Admin-facing description
|
||||||
|
trigger: FlowTrigger; // What starts this flow
|
||||||
|
groups: Group[]; // Ordered containers of blocks
|
||||||
|
edges: Edge[]; // Connections between blocks/groups
|
||||||
|
variables: VariableDefinition[];// Flow-scoped variables
|
||||||
|
version: number; // Start at 1
|
||||||
|
status: 'draft' | 'published'; // Only published flows execute
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' }; // Catch-all when no other flow matches
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
id: string; // "g1", "g2", etc.
|
||||||
|
title: string; // "Greeting", "Department Selection"
|
||||||
|
blocks: Block[]; // Executed in order
|
||||||
|
};
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
id: string; // "e1", "e2", etc.
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type VariableDefinition = {
|
||||||
|
id: string; // "v1", "v2", etc.
|
||||||
|
name: string; // "selectedDepartment"
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Send text, buttons, or list to patient
|
||||||
|
type MessageBlock = {
|
||||||
|
id: string; type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string } // Supports {{variables}}
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] } // Max 3 buttons, title max 20 chars
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] }; // Section title max 24 chars, row title max 24 chars, max 10 rows total
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for patient reply — PAUSES execution
|
||||||
|
type InputBlock = {
|
||||||
|
id: string; type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string; // Store reply in this variable
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Branch based on variable value
|
||||||
|
type ConditionBlock = {
|
||||||
|
id: string; type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string; // "c1" — used in edge.from.conditionId
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string; // Supports {{variables}}
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign or transform a variable
|
||||||
|
type SetVariableBlock = {
|
||||||
|
id: string; type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||||
|
// extract_id: "doc:{uuid}:{name}" → uuid (second segment)
|
||||||
|
// extract_datetime: "slot:{id}:{datetime}" → datetime (third+ segments, rejoined with :)
|
||||||
|
// date_tomorrow/date_day_after: computes date string YYYY-MM-DD
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute a registered tool
|
||||||
|
type ToolCallBlock = {
|
||||||
|
id: string; type: 'tool_call';
|
||||||
|
toolName: string; // Must be a registered tool (see below)
|
||||||
|
inputs: Record<string, string>; // Values support {{variables}} and {{var.field}} dot notation
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic LLM response
|
||||||
|
type AIBlock = {
|
||||||
|
id: string; type: 'ai';
|
||||||
|
prompt: string; // Supports {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean; // true = send as WhatsApp message
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jump to another group
|
||||||
|
type JumpBlock = {
|
||||||
|
id: string; type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Tools (ToolRegistry)
|
||||||
|
|
||||||
|
| Tool Name | Description | Inputs | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `resolve_caller` | Phone → Lead + Patient | phone? (defaults to current) | { leadId, patientId, isNew, phone } |
|
||||||
|
| `send_department_list` | Interactive department list | (none) | { sent, departments[] } |
|
||||||
|
| `send_doctor_list` | Interactive doctor list | department | { sent, count } |
|
||||||
|
| `send_slot_list` | Time slots for doctor+date | doctorId, doctorName, date | { sent, slots } |
|
||||||
|
| `send_confirm_buttons` | Confirm/Cancel buttons | summary | { sent } |
|
||||||
|
| `book_appointment` | Book with conflict check | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId, reference } |
|
||||||
|
| `lookup_appointments` | Check existing appointments | (none — uses current caller) | { appointments[] } |
|
||||||
|
| `send_appointment_qr` | Generate and send QR code | appointmentId, reference, patientName, doctorName, department, scheduledAt | { sent, qrUrl } |
|
||||||
|
|
||||||
|
### System Variables (auto-injected)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `_initialMessage` | The first message the patient sent |
|
||||||
|
| `_senderName` | WhatsApp profile name |
|
||||||
|
| `_phone` | Phone number (E.164 without +) |
|
||||||
|
| `_callerName` | Resolved patient name from platform |
|
||||||
|
| `_leadId` | Lead ID if exists |
|
||||||
|
| `_patientId` | Patient ID if exists |
|
||||||
|
| `_isNew` | true if no prior records |
|
||||||
|
|
||||||
|
### Variable Interpolation
|
||||||
|
|
||||||
|
- `{{variableName}}` — simple substitution
|
||||||
|
- `{{result.fieldName}}` — dot notation for object fields (e.g., `{{bookingResult.appointmentId}}`)
|
||||||
|
- Interactive reply IDs stored in `variableId`, display titles in `variableId_title`
|
||||||
|
|
||||||
|
### WhatsApp Constraints
|
||||||
|
|
||||||
|
- Button title: max 20 characters
|
||||||
|
- List section title: max 24 characters
|
||||||
|
- List row title: max 24 characters
|
||||||
|
- List row description: max 72 characters
|
||||||
|
- Max 3 buttons per message
|
||||||
|
- Max 10 list rows total across all sections
|
||||||
|
- No markdown in text messages (plain text only)
|
||||||
|
- Interactive messages only work within 24h session window
|
||||||
|
|
||||||
|
## How to Generate
|
||||||
|
|
||||||
|
1. **Ask the user** what the flow should do — purpose, steps, what data to collect
|
||||||
|
2. **Design the groups** — each logical phase is a group (Greeting, Selection, Confirmation, etc.)
|
||||||
|
3. **Define variables** — what data flows through the conversation
|
||||||
|
4. **Build blocks** — MessageBlocks for output, InputBlocks to pause for reply, ConditionBlocks for branching, ToolCallBlocks for platform operations, AIBlocks for dynamic responses
|
||||||
|
5. **Wire edges** — connect groups via edges, condition edges for branching
|
||||||
|
6. **Write the JSON** to `src/messaging/flow/default-flows/{flow-name}.json`
|
||||||
|
7. **Register new tools** if needed in `src/messaging/flow/tool-registry.ts`
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See `src/messaging/flow/default-flows/appointment-booking.json` for a complete working example with:
|
||||||
|
- AI greeting
|
||||||
|
- Intent routing (book / check / question)
|
||||||
|
- Interactive lists (departments, doctors, slots)
|
||||||
|
- Date selection with custom date AI parsing
|
||||||
|
- Confirmation buttons
|
||||||
|
- Booking with conflict check
|
||||||
|
- QR code generation
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
After creating the flow JSON:
|
||||||
|
1. `npm run build` — verifies the JSON is copied to dist (via nest-cli.json assets)
|
||||||
|
2. Deploy to EC2 — the flow store auto-seeds on first run if `data/flows/` is empty
|
||||||
|
3. If updating an existing flow: `docker exec sidecar cp /app/dist/.../flow.json /app/data/flows/flow-id.json && docker compose restart sidecar`
|
||||||
@@ -54,5 +54,8 @@ COPY --from=builder /app/dist ./dist
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# Widget embed script (pre-built, served via NestJS static assets)
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
239
docs/website-widget.md
Normal file
239
docs/website-widget.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Website Chat Widget — Operations Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A floating chat/booking/contact widget that hospitals embed on their website via a single `<script>` tag. Visitors can:
|
||||||
|
- **Chat** with an AI assistant (powered by OpenAI/Anthropic)
|
||||||
|
- **Book** appointments (department → doctor → date → slot)
|
||||||
|
- **Contact** the hospital (name, phone, interest — creates a lead in the CRM)
|
||||||
|
|
||||||
|
All interactions create or update leads in Helix Engage, so CC agents see the full visitor journey when they call back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embed Snippet
|
||||||
|
|
||||||
|
Add this to any page on the hospital website (before `</body>`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://ramaiah.engage.healix360.net/widget.js"
|
||||||
|
data-key="956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3">
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `src` | The sidecar URL + `/widget.js` |
|
||||||
|
| `data-key` | Site key — generated and rotatable from the admin portal |
|
||||||
|
|
||||||
|
The widget renders in a **shadow DOM** — its styles don't leak into or get affected by the host website's CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Configuration
|
||||||
|
|
||||||
|
### Settings Page
|
||||||
|
|
||||||
|
URL: `https://ramaiah.engage.healix360.net/settings/widget` (login as admin/supervisor)
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Enabled** | Master kill switch — when off, widget.js no-ops |
|
||||||
|
| **Site Key** | Read-only HMAC-signed key. Copy-to-clipboard for the embed snippet |
|
||||||
|
| **Site ID** | Read-only identifier |
|
||||||
|
| **Rotate Key** | Generates a new key, invalidates the old embed snippet |
|
||||||
|
| **Hosting URL** | Public base URL for widget.js. Leave blank to use same origin as sidecar |
|
||||||
|
| **Allowed Origins** | Whitelist of domains allowed to embed. Empty = any origin (test mode) |
|
||||||
|
| **Show on Login Page** | Toggle to display widget on the Helix Engage login page |
|
||||||
|
|
||||||
|
### Configuration API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read config (public — no auth)
|
||||||
|
curl https://ramaiah.engage.healix360.net/api/config/widget
|
||||||
|
|
||||||
|
# Read full config (admin)
|
||||||
|
curl https://ramaiah.engage.healix360.net/api/config/widget/admin
|
||||||
|
|
||||||
|
# Update config
|
||||||
|
curl -X PUT https://ramaiah.engage.healix360.net/api/config/widget \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"enabled": true, "allowedOrigins": ["https://ramaiahmedical.com"]}'
|
||||||
|
|
||||||
|
# Rotate site key (invalidates old embeds)
|
||||||
|
curl -X POST https://ramaiah.engage.healix360.net/api/config/widget/rotate-key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Key Security
|
||||||
|
|
||||||
|
- Key format: `{siteId}.{hmacSignature}`
|
||||||
|
- HMAC computed as: `sha256(siteId, secret=WIDGET_SECRET env var)`
|
||||||
|
- Validated with `timingSafeEqual()` to prevent timing attacks
|
||||||
|
- Origin checked against `allowedOrigins` whitelist
|
||||||
|
- Empty whitelist = test mode (any origin allowed)
|
||||||
|
|
||||||
|
### For Production
|
||||||
|
|
||||||
|
Before going live on a real hospital website:
|
||||||
|
1. Set `allowedOrigins` to the hospital's domain(s): `["https://ramaiahmedical.com", "https://www.ramaiahmedical.com"]`
|
||||||
|
2. Ensure `WIDGET_SECRET` env var is set in the sidecar (auto-generated on first run if missing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget API Endpoints
|
||||||
|
|
||||||
|
All endpoints require `WidgetKeyGuard` (key as query param `?key=...` or header `X-Widget-Key`).
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/widget/init` | GET | Returns brand name, logo, colors, reCAPTCHA key |
|
||||||
|
| `/api/widget/doctors` | GET | All doctors with departments, specialties, fees, visit slots |
|
||||||
|
| `/api/widget/slots?doctorId=X&date=YYYY-MM-DD` | GET | Available time slots for a doctor on a date |
|
||||||
|
| `/api/widget/book` | POST | Book appointment (requires captcha token) |
|
||||||
|
| `/api/widget/lead` | POST | Create lead from contact form (requires captcha token) |
|
||||||
|
| `/api/widget/chat-start` | POST | Start chat session — body: `{name, phone}`, returns `{leadId}` |
|
||||||
|
| `/api/widget/chat` | POST | Stream AI reply — body: `{leadId, messages[], branch?}`, returns SSE stream |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Chat Flow Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Chat tab shows name + phone form
|
||||||
|
2. Visitor enters name + phone → `POST /api/widget/chat-start` → returns `leadId`
|
||||||
|
- Creates or finds existing lead by phone (deduplication)
|
||||||
|
3. Visitor types a message → `POST /api/widget/chat` with `leadId` + message history
|
||||||
|
- AI streams a reply via Server-Sent Events
|
||||||
|
- AI has tools: branch selection, doctor search, slot lookup, booking suggestions
|
||||||
|
4. After conversation ends, transcript is saved to lead activity timeline
|
||||||
|
5. CC agent sees the WhatsApp/chat history when calling the patient back
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Booking Flow Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Book tab
|
||||||
|
2. Selects department → fetches doctor list
|
||||||
|
3. Selects doctor → fetches available dates/slots
|
||||||
|
4. Enters patient name, phone, chief complaint
|
||||||
|
5. `POST /api/widget/book` with captcha token
|
||||||
|
6. Creates patient + lead + appointment in the platform
|
||||||
|
7. Shows confirmation with reference number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Lead Capture Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Contact tab
|
||||||
|
2. Enters name, phone, interest, optional message
|
||||||
|
3. `POST /api/widget/lead` with captcha token
|
||||||
|
4. Creates lead with source "Website Widget"
|
||||||
|
5. Shows success confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Deduplication
|
||||||
|
|
||||||
|
All three flows (chat, book, contact) use `CallerResolutionService` to find existing leads by phone number. If a visitor chats, then books, then contacts — all within 24 hours — they create ONE lead, not three. Activities are appended to the same lead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## reCAPTCHA Protection
|
||||||
|
|
||||||
|
- Booking and lead endpoints use reCAPTCHA v3 (invisible — no user friction)
|
||||||
|
- Chat endpoints do NOT use reCAPTCHA (session already verified by name+phone gate)
|
||||||
|
- reCAPTCHA site key returned by `/api/widget/init`
|
||||||
|
- Server validates tokens via Google reCAPTCHA API
|
||||||
|
- Captcha can be bypassed for webhooks using `captchaToken: "webhook-bypass"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding & Theming
|
||||||
|
|
||||||
|
The widget pulls branding from the sidecar theme config:
|
||||||
|
- **Hospital name** — displayed in the widget header
|
||||||
|
- **Logo** — shown in the header
|
||||||
|
- **Brand color** — applied to buttons, links, active states
|
||||||
|
- **Location** — shown under the hospital name
|
||||||
|
|
||||||
|
Configure via: `https://ramaiah.engage.healix360.net/settings` → Theme section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Source Code
|
||||||
|
|
||||||
|
| Location | Description |
|
||||||
|
|---|---|
|
||||||
|
| `packages/widget-src/` | Preact source (chat.tsx, booking.tsx, contact.tsx, api.ts, etc.) |
|
||||||
|
| `public/widget.js` | Compiled IIFE bundle (served by NestJS static assets) |
|
||||||
|
| `src/widget/` | Backend API (controller, service, chat service, key guard) |
|
||||||
|
| `src/config/widget-keys.service.ts` | Key generation + HMAC validation |
|
||||||
|
| `src/config/widget-config.service.ts` | Config file management (data/widget.json) |
|
||||||
|
|
||||||
|
### Rebuilding widget.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/widget-src
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
# Output goes to ../../public/widget.js (configured in vite.config.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
After rebuilding:
|
||||||
|
1. Commit `public/widget.js`
|
||||||
|
2. Build Docker image and deploy sidecar
|
||||||
|
3. Widget auto-updates on next page load (1h cache)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Checklist
|
||||||
|
|
||||||
|
### First-time setup on a new tenant:
|
||||||
|
|
||||||
|
1. **Sidecar serves widget.js** — verify `https://{tenant}.engage.healix360.net/widget.js` returns JS, not HTML
|
||||||
|
2. **Caddy routing** — `/widget.js` must route to sidecar, not frontend. Add to the `@api path` matcher in Caddyfile:
|
||||||
|
```
|
||||||
|
@api path /api/* /widget.js /graphql /auth/* ...
|
||||||
|
```
|
||||||
|
3. **Widget config exists** — `GET /api/config/widget` should return `{enabled: true, key: "..."}`
|
||||||
|
4. **Generate key if needed** — `POST /api/widget/keys/generate`
|
||||||
|
5. **Set allowed origins** (for production) — `PUT /api/config/widget` with `allowedOrigins: ["https://hospital.com"]`
|
||||||
|
6. **Test embed** — paste the `<script>` tag into a test HTML page and verify the widget appears
|
||||||
|
7. **Verify AI** — start a chat, confirm AI responds (requires `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar env)
|
||||||
|
|
||||||
|
### Current deployment (Ramaiah):
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Widget URL | `https://ramaiah.engage.healix360.net/widget.js` |
|
||||||
|
| Site Key | `956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3` |
|
||||||
|
| Site ID | `956018d178194fb9` |
|
||||||
|
| Allowed Origins | Empty (test mode — any origin) |
|
||||||
|
| Login Page Widget | Enabled |
|
||||||
|
| reCAPTCHA | Configured (key returned by `/api/widget/init`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Widget doesn't appear
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Verify `VITE_API_URL` in the frontend build points to the sidecar URL (not `localhost`)
|
||||||
|
3. Verify `/api/config/widget` returns `enabled: true` and `embed.loginPage: true`
|
||||||
|
4. Verify `/widget.js` returns actual JavaScript (not HTML from the frontend catch-all)
|
||||||
|
|
||||||
|
### "leadId required" error
|
||||||
|
The chat requires a `chat-start` call first (name + phone → leadId). If the widget skips this step, it's using an old `widget.js`. Clear browser cache or deploy the correct version from commit `aa41a2a`.
|
||||||
|
|
||||||
|
### Chat returns "AI not configured"
|
||||||
|
Missing `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar environment. Check with:
|
||||||
|
```bash
|
||||||
|
docker exec sidecar-ramaiah env | grep -i 'OPENAI\|ANTHROPIC\|AI_PROVIDER'
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
The widget key guard validates the request origin against `allowedOrigins`. If empty, any origin is allowed. If set, the host website's domain must be in the list.
|
||||||
|
|
||||||
|
### Widget shows on login page but not on hospital website
|
||||||
|
The login page injection code is in `helix-engage/src/pages/login.tsx`. For external hospital websites, the embed snippet must be manually added to their HTML. There's no automatic injection for third-party sites.
|
||||||
188
package-lock.json
generated
188
package-lock.json
generated
@@ -21,11 +21,13 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"json-rules-engine": "^6.6.0",
|
"json-rules-engine": "^6.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -5160,6 +5162,15 @@
|
|||||||
"integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==",
|
"integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -6223,7 +6234,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6233,7 +6243,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -6728,7 +6737,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -7013,7 +7021,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -7026,7 +7033,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/colorette": {
|
"node_modules/colorette": {
|
||||||
@@ -7266,6 +7272,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -7434,6 +7449,12 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -7533,7 +7554,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -8656,7 +8676,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -9255,7 +9274,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11240,7 +11258,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -11298,7 +11315,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11657,6 +11673,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -11822,6 +11847,127 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -11954,7 +12100,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -11984,6 +12129,12 @@
|
|||||||
"node": ">=8.6.0"
|
"node": ">=8.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -12269,6 +12420,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -12676,7 +12833,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -12707,7 +12863,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -13929,6 +14084,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -13950,7 +14111,6 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"json-rules-engine": "^6.6.0",
|
"json-rules-engine": "^6.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
18
packages/widget-src/package.json
Normal file
18
packages/widget-src/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "helix-engage-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/widget-src/src/api.ts
Normal file
57
packages/widget-src/src/api.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { WidgetConfig, Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
let baseUrl = '';
|
||||||
|
let widgetKey = '';
|
||||||
|
|
||||||
|
export const initApi = (url: string, key: string) => {
|
||||||
|
baseUrl = url;
|
||||||
|
widgetKey = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Widget-Key': widgetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInit = async (): Promise<WidgetConfig> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Widget init failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDoctors = async (): Promise<Doctor[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load doctors');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load slots');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Booking failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Submission failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ messages, captchaToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
199
packages/widget-src/src/booking.tsx
Normal file
199
packages/widget-src/src/booking.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { fetchDoctors, fetchSlots, submitBooking } from './api';
|
||||||
|
import type { Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
type Step = '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 [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'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredDoctors = selectedDept ? doctors.filter(d => d.department === selectedDept) : [];
|
||||||
|
|
||||||
|
const handleDoctorSelect = (doc: Doctor) => {
|
||||||
|
setSelectedDoctor(doc);
|
||||||
|
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setStep('datetime');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDoctor && selectedDate) {
|
||||||
|
fetchSlots(selectedDoctor.id, selectedDate).then(setSlots).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [selectedDoctor, selectedDate]);
|
||||||
|
|
||||||
|
const handleBook = async () => {
|
||||||
|
if (!selectedDoctor || !selectedSlot || !name || !phone) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const scheduledAt = `${selectedDate}T${selectedSlot}:00`;
|
||||||
|
const result = await submitBooking({
|
||||||
|
departmentId: selectedDept,
|
||||||
|
doctorId: selectedDoctor.id,
|
||||||
|
scheduledAt,
|
||||||
|
patientName: name,
|
||||||
|
patientPhone: phone,
|
||||||
|
chiefComplaint: complaint,
|
||||||
|
captchaToken: 'dev-bypass',
|
||||||
|
});
|
||||||
|
setReference(result.reference);
|
||||||
|
setStep('success');
|
||||||
|
} catch {
|
||||||
|
setError('Booking failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepIndex = { department: 0, doctor: 1, datetime: 2, details: 3, success: 4 };
|
||||||
|
const currentStep = stepIndex[step];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{step !== 'success' && (
|
||||||
|
<div class="widget-steps">
|
||||||
|
{[0, 1, 2, 3].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>}
|
||||||
|
|
||||||
|
{step === 'department' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Select Department</div>
|
||||||
|
{departments.map(dept => (
|
||||||
|
<button
|
||||||
|
key={dept}
|
||||||
|
class="widget-btn widget-btn-secondary"
|
||||||
|
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||||
|
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||||
|
>
|
||||||
|
{dept.replace(/_/g, ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'doctor' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||||
|
Select Doctor — {selectedDept.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
{filteredDoctors.map(doc => (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
class="widget-btn widget-btn-secondary"
|
||||||
|
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||||
|
onClick={() => handleDoctorSelect(doc)}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#6b7280' }}>
|
||||||
|
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `• ${doc.clinic.clinicName}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||||
|
← 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-field">
|
||||||
|
<label class="widget-label">Date</label>
|
||||||
|
<input
|
||||||
|
class="widget-input"
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
onInput={(e: any) => { setSelectedDate(e.target.value); setSelectedSlot(''); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label class="widget-label">Available Slots</label>
|
||||||
|
<div class="widget-slots">
|
||||||
|
{slots.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.time}
|
||||||
|
class={`widget-slot ${s.time === selectedSlot ? 'selected' : ''} ${!s.available ? 'unavailable' : ''}`}
|
||||||
|
onClick={() => s.available && setSelectedSlot(s.time)}
|
||||||
|
disabled={!s.available}
|
||||||
|
>
|
||||||
|
{s.time}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>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)} />
|
||||||
|
</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)} />
|
||||||
|
</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)} />
|
||||||
|
</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}>
|
||||||
|
{loading ? 'Booking...' : 'Book Appointment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">✅</div>
|
||||||
|
<div class="widget-success-title">Appointment Booked!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
Reference: <strong>{reference}</strong><br />
|
||||||
|
{selectedDoctor?.name} • {selectedDate} at {selectedSlot}<br /><br />
|
||||||
|
We'll send a confirmation SMS to your phone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
packages/widget-src/src/chat.tsx
Normal file
94
packages/widget-src/src/chat.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||||
|
import { streamChat } from './api';
|
||||||
|
import type { ChatMessage } from './types';
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = [
|
||||||
|
'Doctor availability',
|
||||||
|
'Clinic timings',
|
||||||
|
'Book appointment',
|
||||||
|
'Health packages',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Chat = () => {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const sendMessage = async (text: string) => {
|
||||||
|
if (!text.trim() || loading) return;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
|
||||||
|
const updated = [...messages, userMsg];
|
||||||
|
setMessages(updated);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await streamChat(updated);
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
|
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 }]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} class={`chat-msg ${msg.role}`}>
|
||||||
|
<div class="chat-bubble">{msg.content || '...'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input
|
||||||
|
class="widget-input chat-input"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
value={input}
|
||||||
|
onInput={(e: any) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
packages/widget-src/src/contact.tsx
Normal file
85
packages/widget-src/src/contact.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { submitLead } from './api';
|
||||||
|
|
||||||
|
export const Contact = () => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [interest, setInterest] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim() || !phone.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await submitLead({
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
interest: interest.trim() || undefined,
|
||||||
|
message: message.trim() || undefined,
|
||||||
|
captchaToken: 'dev-bypass',
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
} catch {
|
||||||
|
setError('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">🙏</div>
|
||||||
|
<div class="widget-success-title">Thank you!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
An agent will call you shortly on {phone}.<br />
|
||||||
|
We typically respond within 30 minutes during business hours.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{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)} />
|
||||||
|
</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)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Interested In</label>
|
||||||
|
<select class="widget-select" value={interest} onChange={(e: any) => setInterest(e.target.value)}>
|
||||||
|
<option value="">Select (optional)</option>
|
||||||
|
<option value="Consultation">General Consultation</option>
|
||||||
|
<option value="Health Checkup">Health Checkup</option>
|
||||||
|
<option value="Surgery">Surgery</option>
|
||||||
|
<option value="Second Opinion">Second Opinion</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</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)} />
|
||||||
|
</div>
|
||||||
|
<button class="widget-btn" disabled={!name.trim() || !phone.trim() || loading} onClick={handleSubmit}>
|
||||||
|
{loading ? 'Sending...' : 'Send Message'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
packages/widget-src/src/icons.ts
Normal file
27
packages/widget-src/src/icons.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// FontAwesome Pro 6.7.2 Duotone SVGs — bundled as inline strings
|
||||||
|
// License: https://fontawesome.com/license (Commercial License)
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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}}`);
|
||||||
|
};
|
||||||
40
packages/widget-src/src/main.tsx
Normal file
40
packages/widget-src/src/main.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { render } from 'preact';
|
||||||
|
import { initApi, fetchInit } from './api';
|
||||||
|
import { Widget } from './widget';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
|
||||||
|
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
|
||||||
|
|
||||||
|
const key = script.getAttribute('data-key') ?? '';
|
||||||
|
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
|
||||||
|
|
||||||
|
initApi(baseUrl, key);
|
||||||
|
|
||||||
|
let config: WidgetConfig;
|
||||||
|
try {
|
||||||
|
config = await fetchInit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HelixWidget] Init failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shadow DOM host
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = 'helix-widget-host';
|
||||||
|
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const mountPoint = document.createElement('div');
|
||||||
|
shadow.appendChild(mountPoint);
|
||||||
|
|
||||||
|
render(<Widget config={config} shadow={shadow} />, mountPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
138
packages/widget-src/src/styles.ts
Normal file
138
packages/widget-src/src/styles.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
export const getStyles = (config: WidgetConfig) => `
|
||||||
|
:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
.widget-bubble {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%;
|
||||||
|
background: ${config.colors.primary}; color: #fff;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.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-panel {
|
||||||
|
width: 380px; height: 520px; border-radius: 16px;
|
||||||
|
background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0;
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.widget-tabs {
|
||||||
|
display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa;
|
||||||
|
}
|
||||||
|
.widget-tab {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.widget-tab.active {
|
||||||
|
color: ${config.colors.primary}; border-bottom-color: ${config.colors.primary};
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px; font-size: 13px; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.widget-input:focus { border-color: ${config.colors.primary}; }
|
||||||
|
.widget-textarea { resize: vertical; min-height: 60px; font-family: inherit; }
|
||||||
|
.widget-select {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px; font-size: 13px; background: #fff; outline: none;
|
||||||
|
}
|
||||||
|
.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; }
|
||||||
|
.widget-field { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.widget-btn {
|
||||||
|
width: 100%; padding: 10px 16px; border: none; border-radius: 8px;
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity 0.15s; color: #fff; background: ${config.colors.primary};
|
||||||
|
}
|
||||||
|
.widget-btn:hover { opacity: 0.9; }
|
||||||
|
.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.widget-btn-secondary { background: #f3f4f6; color: #374151; }
|
||||||
|
|
||||||
|
.widget-slots {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
|
||||||
|
}
|
||||||
|
.widget-slot {
|
||||||
|
padding: 8px; text-align: center; font-size: 12px; border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb; cursor: pointer; background: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.widget-slot:hover { border-color: ${config.colors.primary}; }
|
||||||
|
.widget-slot.selected { background: ${config.colors.primary}; color: #fff; border-color: ${config.colors.primary}; }
|
||||||
|
.widget-slot.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.widget-success {
|
||||||
|
text-align: center; padding: 24px 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; }
|
||||||
|
|
||||||
|
.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-bubble {
|
||||||
|
max-width: 80%; padding: 10px 14px; border-radius: 12px;
|
||||||
|
font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.chat-send:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; 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;
|
||||||
|
}
|
||||||
|
.quick-action:hover { background: ${config.colors.primary}; color: #fff; }
|
||||||
|
|
||||||
|
.widget-steps { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.widget-step {
|
||||||
|
flex: 1; height: 3px; border-radius: 2px; background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.widget-step.active { background: ${config.colors.primary}; }
|
||||||
|
.widget-step.done { background: #059669; }
|
||||||
|
`;
|
||||||
26
packages/widget-src/src/types.ts
Normal file
26
packages/widget-src/src/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type WidgetConfig = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Doctor = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: { firstName: string; lastName: string };
|
||||||
|
department: string;
|
||||||
|
specialty: string;
|
||||||
|
visitingHours: string;
|
||||||
|
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeSlot = {
|
||||||
|
time: string;
|
||||||
|
available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
78
packages/widget-src/src/widget.tsx
Normal file
78
packages/widget-src/src/widget.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
import { getStyles } from './styles';
|
||||||
|
import { icon } from './icons';
|
||||||
|
import { Chat } from './chat';
|
||||||
|
import { Booking } from './booking';
|
||||||
|
import { Contact } from './contact';
|
||||||
|
|
||||||
|
type Tab = 'chat' | 'book' | 'contact';
|
||||||
|
|
||||||
|
type WidgetProps = {
|
||||||
|
config: WidgetConfig;
|
||||||
|
shadow: ShadowRoot;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
shadow.appendChild(style);
|
||||||
|
return () => { shadow.removeChild(style); };
|
||||||
|
}, [config, shadow]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Floating bubble */}
|
||||||
|
{!open && (
|
||||||
|
<button class="widget-bubble" onClick={() => setOpen(true)}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
{open && (
|
||||||
|
<div class="widget-panel">
|
||||||
|
{/* 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>
|
||||||
|
<button class="widget-close" onClick={() => setOpen(false)}>✕</button>
|
||||||
|
{/* Icons bundled from FontAwesome Pro SVGs — static, not user input */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div class="widget-tabs">
|
||||||
|
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
|
||||||
|
<span innerHTML={icon('chat', 14)} /> Chat
|
||||||
|
</button>
|
||||||
|
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
|
||||||
|
<span innerHTML={icon('calendar', 14)} /> Book
|
||||||
|
</button>
|
||||||
|
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
|
||||||
|
<span innerHTML={icon('phone', 14)} /> Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div class="widget-body">
|
||||||
|
{tab === 'chat' && <Chat />}
|
||||||
|
{tab === 'book' && <Booking />}
|
||||||
|
{tab === 'contact' && <Contact />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
packages/widget-src/test.html
Normal file
41
packages/widget-src/test.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Global Hospital — Widget Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px; color: #1f2937; }
|
||||||
|
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||||
|
p { color: #6b7280; line-height: 1.6; }
|
||||||
|
.hero { background: #f0f9ff; border-radius: 12px; padding: 40px; margin: 40px 0; }
|
||||||
|
.hero h2 { color: #1e40af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🏥 Global Hospital, Bangalore</h1>
|
||||||
|
<p>Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.</p>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h2>Book Your Appointment Online</h2>
|
||||||
|
<p>Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Our Departments</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Cardiology</li>
|
||||||
|
<li>Orthopedics</li>
|
||||||
|
<li>Gynecology</li>
|
||||||
|
<li>ENT</li>
|
||||||
|
<li>General Medicine</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 40px; font-size: 12px; color: #9ca3af;">
|
||||||
|
This is a test page for the Helix Engage website widget.
|
||||||
|
The widget loads from the sidecar and renders in a shadow DOM.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Replace SITE_KEY with the generated key -->
|
||||||
|
<script src="http://localhost:4100/widget.js" data-key="8197d39c9ad946ef.31e3b1f492a7380f77ea0c90d2f86d5d4b1ac8f70fd01423ac3d85b87d9aa511"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
packages/widget-src/tsconfig.json
Normal file
14
packages/widget-src/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
22
packages/widget-src/vite.config.ts
Normal file
22
packages/widget-src/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/main.tsx',
|
||||||
|
name: 'HelixWidget',
|
||||||
|
fileName: () => 'widget.js',
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
outDir: './dist',
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: 'esbuild',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -38,6 +38,7 @@ export default () => ({
|
|||||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
||||||
},
|
},
|
||||||
|
sidecarUrl: process.env.SIDECAR_PUBLIC_URL ?? '',
|
||||||
messaging: {
|
messaging: {
|
||||||
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
|
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
|
||||||
gupshup: {
|
gupshup: {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class SetupStateController {
|
|||||||
uiFlags() {
|
uiFlags() {
|
||||||
return {
|
return {
|
||||||
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
||||||
|
telephonyEnabled: process.env.TELEPHONY_ENABLED !== 'false', // default true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,9 +145,16 @@
|
|||||||
"type": "condition",
|
"type": "condition",
|
||||||
"conditions": [
|
"conditions": [
|
||||||
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
|
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
|
||||||
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" }
|
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" },
|
||||||
|
{ "id": "c7", "variableId": "dateChoice", "operator": "contains", "value": "other" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "g4t",
|
||||||
|
"title": "Date - Tomorrow",
|
||||||
|
"blocks": [
|
||||||
{
|
{
|
||||||
"id": "b14",
|
"id": "b14",
|
||||||
"type": "set_variable",
|
"type": "set_variable",
|
||||||
@@ -170,6 +177,33 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "g4c",
|
||||||
|
"title": "Date - Custom",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b15a",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "Please type your preferred date (e.g., April 25 or 25/04/2026)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b15b",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"variableId": "customDateText"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b15c",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "The patient typed this date: \"{{customDateText}}\". Convert it to YYYY-MM-DD format. The current year is 2026. Reply with ONLY the date in YYYY-MM-DD format, nothing else.",
|
||||||
|
"outputVariableId": "selectedDate",
|
||||||
|
"sendToPatient": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "g5",
|
"id": "g5",
|
||||||
"title": "Slot Selection",
|
"title": "Slot Selection",
|
||||||
@@ -273,6 +307,19 @@
|
|||||||
"format": "text",
|
"format": "text",
|
||||||
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
|
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b24a",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_appointment_qr",
|
||||||
|
"inputs": {
|
||||||
|
"appointmentId": "{{bookingResult.appointmentId}}",
|
||||||
|
"reference": "{{bookingResult.reference}}",
|
||||||
|
"patientName": "{{_senderName}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"department": "{{selectedDepartmentTitle}}",
|
||||||
|
"scheduledAt": "{{scheduledDateTime}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -316,10 +363,12 @@
|
|||||||
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
|
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
|
||||||
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
|
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
|
||||||
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
|
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
|
||||||
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g5" } },
|
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g4t" } },
|
||||||
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
|
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
|
||||||
|
{ "id": "e6a", "from": { "blockId": "b13", "conditionId": "c7" }, "to": { "groupId": "g4c" } },
|
||||||
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
|
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
|
||||||
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
|
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e8a", "from": { "blockId": "b15c" }, "to": { "groupId": "g5" } },
|
||||||
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
|
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
|
||||||
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
|
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
|
||||||
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
|
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
|
||||||
|
|||||||
@@ -44,9 +44,28 @@ export class FlowExecutionService {
|
|||||||
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-phone lock to prevent concurrent flow executions
|
||||||
|
private readonly locks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
async handleMessage(message: NormalizedMessage): Promise<void> {
|
async handleMessage(message: NormalizedMessage): Promise<void> {
|
||||||
const { phone } = message;
|
const { phone } = message;
|
||||||
|
|
||||||
|
// Serialize executions per phone — prevent two concurrent flows
|
||||||
|
const existing = this.locks.get(phone);
|
||||||
|
const execute = async () => {
|
||||||
|
if (existing) await existing.catch(() => {});
|
||||||
|
await this._handleMessage(message);
|
||||||
|
};
|
||||||
|
const promise = execute();
|
||||||
|
this.locks.set(phone, promise);
|
||||||
|
await promise.finally(() => {
|
||||||
|
if (this.locks.get(phone) === promise) this.locks.delete(phone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleMessage(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone } = message;
|
||||||
|
|
||||||
// 1. Load existing session or start new flow
|
// 1. Load existing session or start new flow
|
||||||
let session = await this.sessions.load(phone);
|
let session = await this.sessions.load(phone);
|
||||||
let flow: Flow | null = null;
|
let flow: Flow | null = null;
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { Injectable } from '@nestjs/common';
|
|||||||
export class FlowVariableService {
|
export class FlowVariableService {
|
||||||
// Replace {{variableName}} with values from session variables
|
// Replace {{variableName}} with values from session variables
|
||||||
interpolate(template: string, variables: Record<string, any>): string {
|
interpolate(template: string, variables: Record<string, any>): string {
|
||||||
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
|
||||||
const value = variables[name];
|
// Support dot notation: {{bookingResult.appointmentId}}
|
||||||
if (value === undefined || value === null) return match; // keep placeholder if unresolved
|
const parts = path.split('.');
|
||||||
|
let value: any = variables;
|
||||||
|
for (const part of parts) {
|
||||||
|
value = value?.[part];
|
||||||
|
if (value === undefined) return match;
|
||||||
|
}
|
||||||
|
if (value === null) return match;
|
||||||
if (typeof value === 'object') return JSON.stringify(value);
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
return String(value);
|
return String(value);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||||
|
import { QrService } from '../qr.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
|
||||||
import type { ToolHandler, ToolContext } from './flow-types';
|
import type { ToolHandler, ToolContext } from './flow-types';
|
||||||
import type { ListSection, InteractiveButton } from '../types';
|
import type { ListSection, InteractiveButton } from '../types';
|
||||||
@@ -10,10 +12,15 @@ export class ToolRegistry {
|
|||||||
private readonly logger = new Logger(ToolRegistry.name);
|
private readonly logger = new Logger(ToolRegistry.name);
|
||||||
private readonly tools: Map<string, ToolHandler> = new Map();
|
private readonly tools: Map<string, ToolHandler> = new Map();
|
||||||
|
|
||||||
|
private readonly sidecarUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private caller: CallerResolutionService,
|
private caller: CallerResolutionService,
|
||||||
|
private qr: QrService,
|
||||||
|
private config: ConfigService,
|
||||||
) {
|
) {
|
||||||
|
this.sidecarUrl = config.get<string>('sidecarUrl') ?? '';
|
||||||
this.registerDefaults();
|
this.registerDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +142,7 @@ export class ToolRegistry {
|
|||||||
const sections: ListSection[] = [{
|
const sections: ListSection[] = [{
|
||||||
title: targetDate,
|
title: targetDate,
|
||||||
rows: timeSlots.map(s => ({
|
rows: timeSlots.map(s => ({
|
||||||
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00+05:30`,
|
||||||
title: s.time,
|
title: s.time,
|
||||||
description: s.clinic || undefined,
|
description: s.clinic || undefined,
|
||||||
})),
|
})),
|
||||||
@@ -195,7 +202,7 @@ export class ToolRegistry {
|
|||||||
// Book — include patientId so appointment is linked to patient record
|
// Book — include patientId so appointment is linked to patient record
|
||||||
const result = await this.platform.query<any>(
|
const result = await this.platform.query<any>(
|
||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } },
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt: scheduledAt.includes('+') || scheduledAt.includes('Z') ? scheduledAt : `${scheduledAt}+05:30`, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } },
|
||||||
);
|
);
|
||||||
const id = result?.createAppointment?.id;
|
const id = result?.createAppointment?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -215,5 +222,23 @@ export class ToolRegistry {
|
|||||||
);
|
);
|
||||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.register('send_appointment_qr', async (inputs, ctx) => {
|
||||||
|
const { appointmentId, reference, patientName, doctorName, department, scheduledAt } = inputs;
|
||||||
|
if (!appointmentId) return { sent: false, message: 'No appointment ID.' };
|
||||||
|
|
||||||
|
await this.qr.generate(appointmentId, {
|
||||||
|
reference: reference ?? appointmentId.substring(0, 8),
|
||||||
|
patientName: patientName ?? '',
|
||||||
|
doctorName: doctorName ?? '',
|
||||||
|
department: department ?? '',
|
||||||
|
scheduledAt: scheduledAt ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrUrl = `${this.sidecarUrl}/api/messaging/qr/${appointmentId}`;
|
||||||
|
await ctx.provider.sendImage(ctx.phone, qrUrl, `Your appointment QR code — show this at the hospital reception desk.`);
|
||||||
|
this.logger.log(`[TOOL] send_appointment_qr: sent QR for ${reference ?? appointmentId}`);
|
||||||
|
return { sent: true, qrUrl };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Param, Res, Logger } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { MessagingProvider } from './providers/messaging-provider.interface';
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
import { MessagingService } from './messaging.service';
|
import { MessagingService } from './messaging.service';
|
||||||
|
import { QrService } from './qr.service';
|
||||||
|
|
||||||
@Controller('api/messaging')
|
@Controller('api/messaging')
|
||||||
export class MessagingController {
|
export class MessagingController {
|
||||||
@@ -9,6 +11,7 @@ export class MessagingController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly provider: MessagingProvider,
|
private readonly provider: MessagingProvider,
|
||||||
private readonly messaging: MessagingService,
|
private readonly messaging: MessagingService,
|
||||||
|
private readonly qr: QrService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('webhook')
|
@Post('webhook')
|
||||||
@@ -33,4 +36,17 @@ export class MessagingController {
|
|||||||
|
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve QR code images — Gupshup needs a public URL to send images
|
||||||
|
@Get('qr/:appointmentId')
|
||||||
|
async serveQr(@Param('appointmentId') appointmentId: string, @Res() res: Response) {
|
||||||
|
const png = this.qr.get(appointmentId);
|
||||||
|
if (!png) {
|
||||||
|
res.status(404).json({ error: 'QR code not found or expired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Content-Type', 'image/png');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.send(png);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FlowSessionService } from './flow/flow-session.service';
|
|||||||
import { FlowStoreService } from './flow/flow-store.service';
|
import { FlowStoreService } from './flow/flow-store.service';
|
||||||
import { FlowVariableService } from './flow/flow-variable.service';
|
import { FlowVariableService } from './flow/flow-variable.service';
|
||||||
import { ToolRegistry } from './flow/tool-registry';
|
import { ToolRegistry } from './flow/tool-registry';
|
||||||
|
import { QrService } from './qr.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, CallerResolutionModule],
|
imports: [PlatformModule, CallerResolutionModule],
|
||||||
@@ -24,6 +25,7 @@ import { ToolRegistry } from './flow/tool-registry';
|
|||||||
FlowStoreService,
|
FlowStoreService,
|
||||||
FlowVariableService,
|
FlowVariableService,
|
||||||
ToolRegistry,
|
ToolRegistry,
|
||||||
|
QrService,
|
||||||
{
|
{
|
||||||
provide: MessagingProvider,
|
provide: MessagingProvider,
|
||||||
useFactory: (config: ConfigService) => {
|
useFactory: (config: ConfigService) => {
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export class GupshupProvider extends MessagingProvider {
|
|||||||
await this.send(to, JSON.stringify(message));
|
await this.send(to, JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendImage(to: string, imageUrl: string, caption?: string): Promise<void> {
|
||||||
|
const message: any = {
|
||||||
|
type: 'image',
|
||||||
|
originalUrl: imageUrl,
|
||||||
|
previewUrl: imageUrl,
|
||||||
|
};
|
||||||
|
if (caption) message.caption = caption;
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
|
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
|
||||||
const message = {
|
const message = {
|
||||||
type: 'list',
|
type: 'list',
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export abstract class MessagingProvider {
|
|||||||
/** Send interactive list (max 10 rows total across sections) */
|
/** Send interactive list (max 10 rows total across sections) */
|
||||||
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Send an image with optional caption */
|
||||||
|
abstract sendImage(to: string, imageUrl: string, caption?: string): Promise<void>;
|
||||||
|
|
||||||
/** Validate that inbound webhook is authentic */
|
/** Validate that inbound webhook is authentic */
|
||||||
abstract validateWebhook(body: any): boolean;
|
abstract validateWebhook(body: any): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/messaging/qr.service.ts
Normal file
57
src/messaging/qr.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
|
// In-memory cache for generated QR images. Each entry expires after 24h.
|
||||||
|
// Key: appointmentId, Value: { png: Buffer, expiresAt: number }
|
||||||
|
const qrCache = new Map<string, { png: Buffer; expiresAt: number }>();
|
||||||
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QrService {
|
||||||
|
private readonly logger = new Logger(QrService.name);
|
||||||
|
|
||||||
|
// Generate a QR code PNG for an appointment
|
||||||
|
async generate(appointmentId: string, data: {
|
||||||
|
reference: string;
|
||||||
|
patientName: string;
|
||||||
|
doctorName: string;
|
||||||
|
department: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
// QR content — JSON with appointment details for kiosk scanning
|
||||||
|
const qrContent = JSON.stringify({
|
||||||
|
type: 'helix-appointment',
|
||||||
|
id: appointmentId,
|
||||||
|
ref: data.reference,
|
||||||
|
patient: data.patientName,
|
||||||
|
doctor: data.doctorName,
|
||||||
|
department: data.department,
|
||||||
|
scheduledAt: data.scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const png = await QRCode.toBuffer(qrContent, {
|
||||||
|
type: 'png',
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#FFFFFF' },
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache for the image hosting endpoint
|
||||||
|
qrCache.set(appointmentId, { png, expiresAt: Date.now() + TTL_MS });
|
||||||
|
this.logger.log(`[QR] Generated for appointment ${data.reference} (${png.length} bytes)`);
|
||||||
|
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve a cached QR image (for the hosting endpoint)
|
||||||
|
get(appointmentId: string): Buffer | null {
|
||||||
|
const entry = qrCache.get(appointmentId);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
qrCache.delete(appointmentId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.png;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,11 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { LeadsModule } from '../leads/leads.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
|
||||||
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
|
||||||
// module's exports. This module only owns the widget-facing API endpoints
|
|
||||||
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)],
|
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule), LeadsModule, SupervisorModule],
|
||||||
controllers: [WidgetController, WebhooksController],
|
controllers: [WidgetController, WebhooksController],
|
||||||
providers: [WidgetService, WidgetChatService],
|
providers: [WidgetService, WidgetChatService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from '.
|
|||||||
import { ThemeService } from '../config/theme.service';
|
import { ThemeService } from '../config/theme.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { LeadAutoAssignService } from '../leads/lead-auto-assign.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
|
||||||
// Dedup window: any lead created for this phone within the last 24h is
|
// 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
|
// considered the same visitor's lead — chat + book + contact by the same
|
||||||
@@ -15,6 +17,7 @@ export type FindOrCreateLeadOpts = {
|
|||||||
source?: string;
|
source?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
interestedService?: string;
|
interestedService?: string;
|
||||||
|
createPatient?: boolean; // default false — only booking creates patients
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,6 +30,8 @@ export class WidgetService {
|
|||||||
private theme: ThemeService,
|
private theme: ThemeService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private caller: CallerResolutionService,
|
private caller: CallerResolutionService,
|
||||||
|
private autoAssign: LeadAutoAssignService,
|
||||||
|
private supervisor: SupervisorService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -56,10 +61,10 @@ export class WidgetService {
|
|||||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
if (resolved.isNew) {
|
if (resolved.isNew) {
|
||||||
// Net-new visitor — create Patient + Lead with the widget-
|
// Net-new visitor — create Lead. Patient is only created
|
||||||
// collected name. Both records get the real name from the
|
// when explicitly requested (e.g., booking an appointment).
|
||||||
// first moment they exist.
|
|
||||||
let patientId: string | undefined;
|
let patientId: string | undefined;
|
||||||
|
if (opts.createPatient) {
|
||||||
try {
|
try {
|
||||||
const p = await this.platform.queryWithAuth<any>(
|
const p = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
@@ -77,6 +82,7 @@ export class WidgetService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const created = await this.platform.queryWithAuth<any>(
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
@@ -235,7 +241,10 @@ export class WidgetService {
|
|||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
{ data: {
|
{ data: {
|
||||||
name: `${req.patientName.trim() || 'Patient'} — ${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
|
name: `${req.patientName.trim() || 'Patient'} — ${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
|
||||||
scheduledAt: req.scheduledAt,
|
// Ensure IST offset — widget may send bare datetime without timezone
|
||||||
|
scheduledAt: req.scheduledAt.includes('+') || req.scheduledAt.includes('Z')
|
||||||
|
? req.scheduledAt
|
||||||
|
: `${req.scheduledAt}+05:30`,
|
||||||
durationMin: 30,
|
durationMin: 30,
|
||||||
appointmentType: 'CONSULTATION',
|
appointmentType: 'CONSULTATION',
|
||||||
status: 'SCHEDULED',
|
status: 'SCHEDULED',
|
||||||
@@ -256,6 +265,7 @@ export class WidgetService {
|
|||||||
source: 'WEBSITE',
|
source: 'WEBSITE',
|
||||||
status: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||||
|
createPatient: true,
|
||||||
});
|
});
|
||||||
// Idempotent upgrade: if the lead was reused from an earlier chat/
|
// Idempotent upgrade: if the lead was reused from an earlier chat/
|
||||||
// contact, promote its status and reflect the new interest.
|
// contact, promote its status and reflect the new interest.
|
||||||
@@ -271,6 +281,13 @@ export class WidgetService {
|
|||||||
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||||
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||||
|
|
||||||
|
// Emit SSE so agents see the new appointment immediately
|
||||||
|
this.supervisor.emitWorklistUpdate({
|
||||||
|
type: 'widget-appointment',
|
||||||
|
callerPhone: this.normalizePhone(req.patientPhone),
|
||||||
|
callerName: req.patientName,
|
||||||
|
});
|
||||||
|
|
||||||
return { appointmentId: appt.createAppointment.id, reference };
|
return { appointmentId: appt.createAppointment.id, reference };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +298,18 @@ export class WidgetService {
|
|||||||
interestedService: req.interest ?? 'Website Enquiry',
|
interestedService: req.interest ?? 'Website Enquiry',
|
||||||
});
|
});
|
||||||
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
|
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
|
||||||
|
|
||||||
|
// Trigger immediate auto-assign + SSE so agent sees the lead instantly
|
||||||
|
this.autoAssign.runOnce().then((result) => {
|
||||||
|
if (result.assigned > 0) {
|
||||||
|
this.supervisor.emitWorklistUpdate({
|
||||||
|
type: 'widget-lead',
|
||||||
|
callerPhone: this.normalizePhone(req.phone),
|
||||||
|
callerName: req.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return { leadId };
|
return { leadId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "widget-src", "public", "data", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "widget-src", "packages", "public", "data", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,5 @@
|
|||||||
"strictBindCallApply": true,
|
"strictBindCallApply": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"exclude": ["widget-src", "public", "data"]
|
"exclude": ["widget-src", "packages", "public", "data"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user