461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|