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

685 lines
26 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, BarChart2, Eraser } from "lucide-react";
import {
conciergeBotsApi,
uploadMediaFile,
whatsappTemplatesApi,
type ConciergeBotRecord,
type ConciergeBotReception,
type ConciergeDripStats,
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";
import { FlatpickrDatetimeInput, toDatetimeLocalValue } from "../ui/FlatpickrDatetimeInput";
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),
allowNonRegistered: Boolean(r.allowNonRegistered),
prefillText: r.prefillText || "Hi",
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
welcomeCtas: Array.isArray(r.welcomeCtas) ? r.welcomeCtas : [],
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);
const [qrWithLogo, setQrWithLogo] = useState(false);
const [qrDownloading, setQrDownloading] = useState<"png" | "svg" | null>(null);
const [statsDripId, setStatsDripId] = useState<string | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState<string | null>(null);
const [stats, setStats] = useState<ConciergeDripStats | null>(null);
const [clearingSessions, setClearingSessions] = useState(false);
useEffect(() => {
setReception(receptionFromBot(bot));
}, [bot._id, bot.updatedAt]);
const loadInfo = useCallback(async () => {
const res = await conciergeBotsApi.getReception(bot._id, { logo: qrWithLogo });
if (!res.error && res.data) setInfo(res.data);
}, [bot._id, qrWithLogo]);
useEffect(() => {
void loadInfo();
}, [loadInfo]);
useEffect(() => {
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
if (!r.error) setTemplates(r.data?.templates || []);
});
}, []);
const downloadQr = async (format: "png" | "svg") => {
setQrDownloading(format);
setMsg(null);
try {
await conciergeBotsApi.downloadReceptionQr(bot._id, format, { logo: qrWithLogo });
} catch (e) {
setMsg(e instanceof Error ? e.message : "QR download failed");
} finally {
setQrDownloading(null);
}
};
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 loadDripStats = async (dripId: string) => {
if (!dripId) {
setStatsError("Save reception first so this drip gets an id.");
setStats(null);
setStatsDripId(null);
return;
}
if (statsDripId === dripId) {
setStatsDripId(null);
setStats(null);
setStatsError(null);
return;
}
setStatsDripId(dripId);
setStatsLoading(true);
setStatsError(null);
setStats(null);
try {
const res = await conciergeBotsApi.getDripStats(bot._id, dripId);
if (res.error) {
setStatsError(res.error);
setStats(null);
} else {
setStats(res.data?.stats ?? null);
}
} catch {
setStatsError("Failed to load drip stats.");
setStats(null);
} finally {
setStatsLoading(false);
}
};
const save = async () => {
setSaving(true);
setMsg(null);
let dripsPayload: ConciergeReceptionDrip[];
let welcomeCtas: WhatsAppCtaItem[];
try {
welcomeCtas = normalizeCtasForSave((reception.welcomeCtas || []) as WhatsAppCtaItem[]);
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,
welcomeCtas,
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 clearReceptionList = async () => {
const count = info?.sessionCount ?? 0;
const ok = window.confirm(
count
? `Clear ${count} reception WhatsApp visitor${count === 1 ? "" : "s"} for this chatbot? The next message from those numbers will send the welcome and drip sequence again.`
: "Clear the reception WhatsApp list for this chatbot? The next message from any previous number will send the welcome and drip sequence again.",
);
if (!ok) return;
setClearingSessions(true);
setMsg(null);
try {
const res = await conciergeBotsApi.clearReceptionSessions(bot._id);
if (res.error) {
setMsg(res.error);
return;
}
const n = res.data?.sessionsCleared ?? 0;
setMsg(
n
? `Cleared ${n} reception visitor${n === 1 ? "" : "s"}. Next inbound from those numbers is treated as new.`
: "Reception list was already empty.",
);
await loadInfo();
} finally {
setClearingSessions(false);
}
};
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>
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 10 }}>
<input
type="checkbox"
checked={Boolean(reception.allowNonRegistered)}
onChange={(e) => patch({ allowNonRegistered: e.target.checked })}
disabled={!info?.canSendMessages}
style={{ marginTop: 3 }}
/>
<span>
Allow non-registered participants
<span style={{ display: "block", fontSize: 11, color: "var(--muted-foreground)", marginTop: 2 }}>
If their WhatsApp number or name is not on the guest list, add them to this event using their phone
number and profile name. Badge matching still runs when the match is unclear.
</span>
</span>
</label>
<div style={{ margin: "4px 0 12px" }}>
<button
type="button"
style={btnSecondary}
disabled={clearingSessions}
onClick={() => void clearReceptionList()}
>
<Eraser size={14} />
{clearingSessions
? "Clearing…"
: info?.sessionCount
? `Clear reception list (${info.sessionCount})`
: "Clear reception list"}
</button>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "6px 0 0" }}>
Removes all WhatsApp visitors for this desk so the next message from an existing number is treated as
new (welcome + drips).
</p>
</div>
{!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 }}
/>
<label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8, fontSize: 13 }}>
<input
type="checkbox"
checked={qrWithLogo}
onChange={(e) => setQrWithLogo(e.target.checked)}
/>
Include WhatsApp logo
</label>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 8px" }}>
Logo uses high error correction so the code still scans. Prefer Meta’s brand guidelines for posters.
</p>
<div style={{ marginTop: 4, display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
<a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
Open wa.me link
</a>
<button
type="button"
style={btnSecondary}
disabled={Boolean(qrDownloading)}
onClick={() => void downloadQr("png")}
>
<Download size={14} /> {qrDownloading === "png" ? "Downloading…" : "Download PNG"}
</button>
<button
type="button"
style={btnSecondary}
disabled={Boolean(qrDownloading)}
onClick={() => void downloadQr("svg")}
>
<Download size={14} /> {qrDownloading === "svg" ? "Downloading…" : "Download SVG"}
</button>
</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}"}, {"{engagementUrl}"}
</p>
<div style={{ marginBottom: 12 }}>
<WhatsAppCtaEditor
value={(reception.welcomeCtas || []) as WhatsAppCtaItem[]}
onChange={(welcomeCtas) => patch({ welcomeCtas })}
/>
</div>
<label>
If not matched — ask for badge photo
<textarea
style={{ ...IS, minHeight: 64 }}
value={reception.unmatchedBadgePrompt || ""}
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
/>
</label>
{reception.allowNonRegistered && (
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
With non-registered guests allowed, this prompt is only used when a match is unclear (for example several
people share the same name).
</p>
)}
<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).
Unique profile links: put {"{engagementUrl}"} in the body or as a URL button (session window only).
Approved templates outside 24h cannot take a fully dynamic button URL unless Meta allows a suffix variable.
</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, gap: 8, flexWrap: "wrap" }}>
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
style={btnSecondary}
title={drip._id ? "Show send stats for this drip" : "Save reception first to track stats"}
disabled={!drip._id || (statsLoading && statsDripId === drip._id)}
onClick={() => void loadDripStats(String(drip._id || ""))}
>
<BarChart2 size={14} /> Stats
</button>
<button
type="button"
style={{ ...btnSecondary, color: "var(--destructive)" }}
onClick={() => {
if (drip._id && statsDripId === drip._id) {
setStatsDripId(null);
setStats(null);
setStatsError(null);
}
patch({ drips: (reception.drips || []).filter((_, j) => j !== i) });
}}
>
<Trash2 size={14} /> Remove
</button>
</div>
</div>
{drip._id && statsDripId === drip._id && (
<div
style={{
marginBottom: 10,
padding: 10,
borderRadius: 8,
background: "var(--secondary)",
border: "1px solid var(--border)",
fontSize: 13,
}}
>
{statsLoading ? (
<p style={{ margin: 0, color: "var(--muted)" }}>Loading stats…</p>
) : statsError ? (
<p style={{ margin: 0, color: "var(--destructive)" }}>{statsError}</p>
) : stats ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 8 }}>
{(
[
["Pending", stats.pending],
["Sent", stats.sent],
["Skipped", stats.skipped],
["Failed", stats.failed],
["Total", stats.total],
] as const
).map(([label, value]) => (
<div key={label}>
<div style={{ fontSize: 10, textTransform: "uppercase", color: "var(--muted)", fontWeight: 600 }}>
{label}
</div>
<div style={{ fontWeight: 600 }}>{value}</div>
</div>
))}
</div>
) : (
<p style={{ margin: 0, color: "var(--muted)" }}>No jobs yet for this drip.</p>
)}
</div>
)}
{!drip._id && (
<p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--muted)" }}>
Save reception to enable stats for this new drip.
</p>
)}
<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
<FlatpickrDatetimeInput
style={IS}
value={toDatetimeLocalValue(drip.sendAt || undefined)}
onChange={(local) =>
patchDrip(i, {
sendAt: local ? new Date(local).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>
);
}