mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
chore: move widget source into sidecar repo (widget-src/)
Widget builds from widget-src/ → public/widget.js Vite outDir updated to ../public .gitignore excludes node_modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
widget-src/.gitignore
vendored
Normal file
1
widget-src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
2070
widget-src/package-lock.json
generated
Normal file
2070
widget-src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
widget-src/package.json
Normal file
18
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
widget-src/src/api.ts
Normal file
57
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
widget-src/src/booking.tsx
Normal file
199
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
widget-src/src/chat.tsx
Normal file
94
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
widget-src/src/contact.tsx
Normal file
85
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
widget-src/src/icons.ts
Normal file
27
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
widget-src/src/main.tsx
Normal file
40
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
widget-src/src/styles.ts
Normal file
138
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
widget-src/src/types.ts
Normal file
26
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
widget-src/src/widget.tsx
Normal file
78
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
widget-src/test.html
Normal file
41
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
widget-src/tsconfig.json
Normal file
14
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
widget-src/vite.config.ts
Normal file
22
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: '../public',
|
||||
emptyOutDir: false,
|
||||
minify: 'esbuild',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user