mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
docs: website widget operations guide + archive widget source
- Comprehensive docs: embed snippet, key management, API endpoints, chat/booking/contact flows, lead dedup, reCAPTCHA, branding, deploy checklist, troubleshooting - Widget Preact source archived in packages/widget-src/ (was only on local machine, not tracked in any repo) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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.
|
||||
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: '../../helix-engage-server/public',
|
||||
emptyOutDir: false,
|
||||
minify: 'esbuild',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user