EventBuzz_Console/src/app/components/whatsapp-chatbot/WhatsAppReceptionTab.tsx

461 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useState } from "react";
import { Plus, Trash2, Download, Settings } from "lucide-react";
import {
conciergeBotsApi,
uploadMediaFile,
whatsappTemplatesApi,
type ConciergeBotRecord,
type ConciergeBotReception,
type ConciergeReceptionDrip,
type ConciergeReceptionInfo,
} from "../../../services/api";
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
import { useAppNav } from "../../contexts/AppNavContext";
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
const IS: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--input-background)",
fontSize: "var(--text-sm)",
color: "var(--card-foreground)",
boxSizing: "border-box",
};
const btnPrimary: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: "pointer",
fontSize: "var(--text-sm)",
};
const btnSecondary: React.CSSProperties = {
...btnPrimary,
background: "var(--secondary)",
color: "var(--card-foreground)",
border: "1px solid var(--border)",
};
const DEFAULT_WELCOME = "Hi {firstName}! Welcome to {eventName}.";
const DEFAULT_BADGE =
"We couldn't match your WhatsApp to a registration. Please send a clear photo of your badge QR code.";
const DEFAULT_STAFF =
"We still couldn't identify you. Please see the reception desk for help. You can also send another badge photo.";
function emptyDrip(): ConciergeReceptionDrip {
return {
trigger: "after_first_inbound",
delayMinutes: 15,
sendAt: "",
body: "",
mediaType: "none",
mediaUrl: "",
contentKind: "custom",
agendaCount: 3,
templateId: "",
ctas: [],
};
}
function receptionFromBot(bot: ConciergeBotRecord): ConciergeBotReception {
const r = bot.reception || {};
return {
enabled: Boolean(r.enabled),
prefillText: r.prefillText || "Hi",
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE,
staffHandoffMessage: r.staffHandoffMessage || DEFAULT_STAFF,
dripAudience: r.dripAudience === "all_inbound" ? "all_inbound" : "matched",
drips: Array.isArray(r.drips) && r.drips.length ? r.drips.map((d) => ({ ...emptyDrip(), ...d })) : [],
};
}
export function WhatsAppReceptionTab({
bot,
eventId,
onSaved,
}: {
bot: ConciergeBotRecord;
eventId: string;
onSaved: () => Promise<void> | void;
}) {
const { setActiveNav, setSettingsInitialSection } = useAppNav();
const [reception, setReception] = useState<ConciergeBotReception>(() => receptionFromBot(bot));
const [info, setInfo] = useState<ConciergeReceptionInfo | null>(null);
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
const [msg, setMsg] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
setReception(receptionFromBot(bot));
}, [bot._id, bot.updatedAt]);
const loadInfo = useCallback(async () => {
const res = await conciergeBotsApi.getReception(bot._id);
if (!res.error && res.data) setInfo(res.data);
}, [bot._id]);
useEffect(() => {
loadInfo();
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
if (!r.error) setTemplates(r.data?.templates || []);
});
}, [loadInfo]);
const patch = (partial: Partial<ConciergeBotReception>) => setReception((prev) => ({ ...prev, ...partial }));
const patchDrip = (index: number, partial: Partial<ConciergeReceptionDrip>) => {
const drips = [...(reception.drips || [])];
drips[index] = { ...drips[index], ...partial };
patch({ drips });
};
const save = async () => {
setSaving(true);
setMsg(null);
let dripsPayload: ConciergeReceptionDrip[];
try {
dripsPayload = (reception.drips || []).map((d) => {
const ctas =
d.contentKind === "upcoming_agenda" ? [] : normalizeCtasForSave((d.ctas || []) as WhatsAppCtaItem[]);
if (ctas.length && !String(d.body || "").trim()) {
throw new Error("Drip message text is required when buttons are set.");
}
return {
_id: d._id,
trigger: d.trigger,
delayMinutes: d.delayMinutes,
body: d.body,
mediaType: d.contentKind === "upcoming_agenda" ? "none" : d.mediaType,
mediaUrl: d.contentKind === "upcoming_agenda" ? "" : d.mediaUrl,
contentKind: d.contentKind || "custom",
agendaCount: d.agendaCount,
templateId: d.templateId || null,
sendAt: d.sendAt || null,
ctas,
};
});
} catch (e) {
setSaving(false);
setMsg(e instanceof Error ? e.message : "Invalid buttons");
return;
}
const res = await conciergeBotsApi.update(bot._id, {
reception: {
...reception,
drips: dripsPayload,
},
});
setSaving(false);
if (res.error) {
setMsg(res.error);
return;
}
setMsg("Reception settings saved");
await onSaved();
await loadInfo();
};
const uploadDripMedia = async (index: number, file: File) => {
const up = await uploadMediaFile(file, { eventId: eventId || "portfolio", module: "whatsapp/reception-drip" });
if (up.error || !up.url) {
setMsg(up.error || "Upload failed");
return;
}
const mime = String(file.type || "").toLowerCase();
const mediaType = mime.startsWith("video/") ? "video" : "image";
patchDrip(index, { mediaUrl: up.url, mediaType });
};
const goSettings = () => {
setSettingsInitialSection("whatsapp");
setActiveNav("settings");
};
const outside24h = (d: ConciergeReceptionDrip) => {
if (d.trigger === "absolute") return true;
return Number(d.delayMinutes || 0) > 24 * 60;
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxWidth: 720 }}>
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
<h3 style={{ margin: "0 0 10px", fontSize: 14 }}>Desk / reception</h3>
<label style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
<input
type="checkbox"
checked={Boolean(reception.enabled)}
onChange={(e) => patch({ enabled: e.target.checked })}
disabled={!info?.canSendMessages}
/>
Enable reception check-in
</label>
{!info?.canSendMessages && (
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
WhatsApp Cloud is not ready to send. Configure WABA ID, token, and phone number ID in Organisation
settings.
<button type="button" style={{ ...btnSecondary, marginLeft: 8 }} onClick={goSettings}>
<Settings size={14} /> WhatsApp settings
</button>
</p>
)}
{info?.resolveError && info.canSendMessages && (
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "0 0 8px" }}>{info.resolveError}</p>
)}
{info?.displayPhone && (
<p style={{ fontSize: 13, margin: "0 0 8px" }}>
Desk number (from organisation settings): <strong>{info.displayPhone}</strong>
</p>
)}
{info?.qrPngBase64 && (
<div style={{ marginBottom: 10 }}>
<img
alt="Reception WhatsApp QR"
src={`data:image/png;base64,${info.qrPngBase64}`}
style={{ width: 180, height: 180, background: "#fff", borderRadius: 8 }}
/>
<div style={{ marginTop: 8, display: "flex", gap: 8, flexWrap: "wrap" }}>
<a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
Open wa.me link
</a>
<a
href={`data:image/png;base64,${info.qrPngBase64}`}
download="reception-whatsapp-qr.png"
style={{ ...btnSecondary, textDecoration: "none" }}
>
<Download size={14} /> Download QR
</a>
</div>
</div>
)}
<label>
Prefill message
<input
style={IS}
value={reception.prefillText || ""}
onChange={(e) => patch({ prefillText: e.target.value })}
/>
</label>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
Visitors scan the QR and send this text (default Hi). WhatsApp Cloud credentials stay in Organisation
settings.
</p>
<label>
Welcome message
<textarea
style={{ ...IS, minHeight: 72 }}
value={reception.welcomeMessage || ""}
onChange={(e) => patch({ welcomeMessage: e.target.value })}
/>
</label>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}
</p>
<label>
If not matched — ask for badge photo
<textarea
style={{ ...IS, minHeight: 64 }}
value={reception.unmatchedBadgePrompt || ""}
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
/>
</label>
<label style={{ marginTop: 8, display: "block" }}>
After badge fails — see reception
<textarea
style={{ ...IS, minHeight: 64 }}
value={reception.staffHandoffMessage || ""}
onChange={(e) => patch({ staffHandoffMessage: e.target.value })}
/>
</label>
</section>
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
<h3 style={{ margin: "0 0 10px", fontSize: 14 }}>Drip messages</h3>
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
Drips are queued when a visitor messages (or is identified), not when you click Save.
If someone already checked in, they need to send another WhatsApp message to pick up a newly added drip.
Upcoming agenda drips list the next sessions at send time (not when you save).
</p>
<label style={{ display: "block", marginBottom: 10 }}>
Send drips to
<select
style={IS}
value={reception.dripAudience || "matched"}
onChange={(e) => patch({ dripAudience: e.target.value as "matched" | "all_inbound" })}
>
<option value="matched">Matched participants only</option>
<option value="all_inbound">Everyone who messaged (including unidentified)</option>
</select>
</label>
{(reception.drips || []).map((drip, i) => (
<div
key={drip._id || i}
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
<button
type="button"
style={{ ...btnSecondary, color: "var(--destructive)" }}
onClick={() => patch({ drips: (reception.drips || []).filter((_, j) => j !== i) })}
>
<Trash2 size={14} /> Remove
</button>
</div>
<label>
Type
<select
style={IS}
value={drip.contentKind || "custom"}
onChange={(e) =>
patchDrip(i, {
contentKind: e.target.value as ConciergeReceptionDrip["contentKind"],
...(e.target.value === "upcoming_agenda" ? { mediaType: "none" as const, mediaUrl: "" } : {}),
})
}
>
<option value="custom">Custom message</option>
<option value="upcoming_agenda">Upcoming agenda</option>
</select>
</label>
<label>
When
<select
style={IS}
value={drip.trigger || "after_first_inbound"}
onChange={(e) =>
patchDrip(i, { trigger: e.target.value as ConciergeReceptionDrip["trigger"] })
}
>
<option value="after_first_inbound">Minutes after first inbound</option>
<option value="after_match">Minutes after we identify them</option>
<option value="absolute">Fixed date and time</option>
</select>
</label>
{drip.trigger === "absolute" ? (
<label>
Send at
<input
type="datetime-local"
style={IS}
value={drip.sendAt ? String(drip.sendAt).slice(0, 16) : ""}
onChange={(e) => patchDrip(i, { sendAt: e.target.value ? new Date(e.target.value).toISOString() : "" })}
/>
</label>
) : (
<label>
Delay (minutes)
<input
type="number"
min={0}
style={IS}
value={drip.delayMinutes ?? 0}
onChange={(e) => patchDrip(i, { delayMinutes: Number(e.target.value) })}
/>
</label>
)}
{drip.contentKind === "upcoming_agenda" && (
<label>
How many upcoming sessions
<input
type="number"
min={1}
max={8}
style={IS}
value={drip.agendaCount ?? 3}
onChange={(e) => patchDrip(i, { agendaCount: Number(e.target.value) })}
/>
</label>
)}
<label>
{drip.contentKind === "upcoming_agenda" ? "Intro (optional)" : "Body"}
<textarea
style={{ ...IS, minHeight: 64 }}
value={drip.body || ""}
onChange={(e) => patchDrip(i, { body: e.target.value })}
placeholder={
drip.contentKind === "upcoming_agenda"
? "e.g. Here are the next sessions on the agenda."
: undefined
}
/>
</label>
{drip.contentKind !== "upcoming_agenda" && (
<>
<label>
Media
<input
type="file"
accept="image/*,video/*"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadDripMedia(i, f);
e.target.value = "";
}}
/>
</label>
{drip.mediaUrl && (
<p style={{ fontSize: 11, margin: "4px 0" }}>
{drip.mediaType}: {drip.mediaUrl}
<button type="button" style={btnSecondary} onClick={() => patchDrip(i, { mediaUrl: "", mediaType: "none" })}>
Clear
</button>
</p>
)}
<WhatsAppCtaEditor
value={(drip.ctas || []) as WhatsAppCtaItem[]}
onChange={(ctas) => patchDrip(i, { ctas })}
compact
/>
</>
)}
{outside24h(drip) && drip.contentKind === "upcoming_agenda" && (
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "6px 0" }}>
If this fires after WhatsApp’s 24-hour window, the agenda drip is skipped (live session lists cannot use a template).
</p>
)}
{outside24h(drip) && drip.contentKind !== "upcoming_agenda" && (
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "6px 0" }}>
This send may fall outside WhatsApp’s 24-hour window. Pick an approved template.
</p>
)}
{drip.contentKind !== "upcoming_agenda" && (
<label>
Template (if outside 24h)
<select
style={IS}
value={drip.templateId || ""}
onChange={(e) => patchDrip(i, { templateId: e.target.value })}
>
<option value="">None</option>
{templates.map((t) => (
<option key={t._id} value={t._id}>
{t.name} ({t.language})
</option>
))}
</select>
</label>
)}
</div>
))}
<button type="button" style={btnSecondary} onClick={() => patch({ drips: [...(reception.drips || []), emptyDrip()] })}>
<Plus size={14} /> Add drip message
</button>
</section>
{msg && <p style={{ fontSize: 13, margin: 0 }}>{msg}</p>}
<button type="button" style={btnPrimary} disabled={saving} onClick={() => void save()}>
{saving ? "Saving…" : "Save reception"}
</button>
</div>
);
}