341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import { whatsappTemplatesApi } from "../../../services/api";
|
|
import type { WaOverrideSlot } from "../../../services/api/campaigns";
|
|
import type {
|
|
AttendanceWhatsAppNotifySettings,
|
|
WhatsAppTemplateRecord,
|
|
WhatsAppVariableBindings,
|
|
} from "../../../services/api/types";
|
|
import { WA_TEMPLATE_MERGE_OPTIONS } from "../messaging/mergePlaceholders";
|
|
|
|
type SlotMode = "default" | "merge" | "fixed";
|
|
type SlotRow = { mode: SlotMode; mergeKey?: string; fixed?: string };
|
|
|
|
function emptySlotRow(): SlotRow {
|
|
return { mode: "default" };
|
|
}
|
|
|
|
function parseSlotsFromOverrides(
|
|
n: number,
|
|
section: "header" | "body" | "url",
|
|
notify: AttendanceWhatsAppNotifySettings | undefined,
|
|
defaultBindings: string[] | undefined,
|
|
): SlotRow[] {
|
|
const o = notify?.waOverrides?.[section];
|
|
const out: SlotRow[] = [];
|
|
for (let i = 0; i < n; i += 1) {
|
|
const slot = String(i + 1);
|
|
const raw = o?.[slot] as WaOverrideSlot | undefined;
|
|
if (!raw) {
|
|
out.push({ mode: "default" });
|
|
continue;
|
|
}
|
|
if (raw.kind === "fixed") {
|
|
out.push({ mode: "fixed", fixed: raw.value || "" });
|
|
} else {
|
|
const def = String(defaultBindings?.[i] || "").trim();
|
|
if (def && raw.key === def) {
|
|
out.push({ mode: "default" });
|
|
} else {
|
|
out.push({ mode: "merge", mergeKey: raw.key || "" });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildSectionOverrides(
|
|
rows: SlotRow[],
|
|
defaultBindings: string[] | undefined,
|
|
): Record<string, WaOverrideSlot> | undefined {
|
|
const out: Record<string, WaOverrideSlot> = {};
|
|
for (let i = 0; i < rows.length; i += 1) {
|
|
const row = rows[i];
|
|
const def = String(defaultBindings?.[i] || "").trim();
|
|
if (row.mode === "default") continue;
|
|
if (row.mode === "fixed") {
|
|
out[String(i + 1)] = { kind: "fixed", value: String(row.fixed || "") };
|
|
} else if (row.mode === "merge") {
|
|
const k = String(row.mergeKey || "").trim();
|
|
if (k && k !== def) out[String(i + 1)] = { kind: "merge", key: k };
|
|
}
|
|
}
|
|
return Object.keys(out).length ? out : undefined;
|
|
}
|
|
|
|
function hasImageHeader(components: Record<string, unknown>[]): boolean {
|
|
return components.some(
|
|
(c) =>
|
|
String(c.type || "").toUpperCase() === "HEADER" && String(c.format || "").toUpperCase() === "IMAGE",
|
|
);
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
function SlotTable({
|
|
title,
|
|
rows,
|
|
setRows,
|
|
defaultBindings,
|
|
}: {
|
|
title: string;
|
|
rows: SlotRow[];
|
|
setRows: (next: SlotRow[]) => void;
|
|
defaultBindings: string[];
|
|
}) {
|
|
if (!rows.length) return null;
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<div style={{ fontSize: 12, fontWeight: 600 }}>{title}</div>
|
|
{rows.map((row, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "72px 100px 1fr",
|
|
gap: 8,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 12, color: "var(--muted)" }}>{`{{${idx + 1}}}`}</span>
|
|
<select
|
|
style={IS}
|
|
value={row.mode}
|
|
onChange={(e) => {
|
|
const mode = e.target.value as SlotMode;
|
|
const next = [...rows];
|
|
next[idx] =
|
|
mode === "merge"
|
|
? {
|
|
mode: "merge",
|
|
mergeKey:
|
|
WA_TEMPLATE_MERGE_OPTIONS.find((o) => o.mergeKey)?.mergeKey ||
|
|
"contact.firstName",
|
|
}
|
|
: mode === "fixed"
|
|
? { mode: "fixed", fixed: "" }
|
|
: { mode: "default" };
|
|
setRows(next);
|
|
}}
|
|
>
|
|
<option value="default">Template default</option>
|
|
<option value="merge">Merge field</option>
|
|
<option value="fixed">Fixed text</option>
|
|
</select>
|
|
{row.mode === "merge" ? (
|
|
<select
|
|
style={IS}
|
|
value={row.mergeKey || ""}
|
|
onChange={(e) => {
|
|
const next = [...rows];
|
|
next[idx] = { mode: "merge", mergeKey: e.target.value };
|
|
setRows(next);
|
|
}}
|
|
>
|
|
{WA_TEMPLATE_MERGE_OPTIONS.filter((o) => o.mergeKey).map((o) => (
|
|
<option key={o.mergeKey} value={o.mergeKey}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : row.mode === "fixed" ? (
|
|
<input
|
|
style={IS}
|
|
value={row.fixed || ""}
|
|
placeholder="Fixed value"
|
|
onChange={(e) => {
|
|
const next = [...rows];
|
|
next[idx] = { mode: "fixed", fixed: e.target.value };
|
|
setRows(next);
|
|
}}
|
|
/>
|
|
) : (
|
|
<span style={{ fontSize: 11, color: "var(--muted)" }}>
|
|
{defaultBindings[idx] ? `Uses ${defaultBindings[idx]}` : "Sample / binding from template"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AttendanceWhatsAppNotifyFields({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: AttendanceWhatsAppNotifySettings;
|
|
onChange: (next: AttendanceWhatsAppNotifySettings) => void;
|
|
}) {
|
|
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
|
|
const [detail, setDetail] = useState<WhatsAppTemplateRecord | null>(null);
|
|
const [headerRows, setHeaderRows] = useState<SlotRow[]>([]);
|
|
const [bodyRows, setBodyRows] = useState<SlotRow[]>([]);
|
|
const [urlRows, setUrlRows] = useState<SlotRow[]>([]);
|
|
const [showImage, setShowImage] = useState(false);
|
|
|
|
useEffect(() => {
|
|
void whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
|
|
if (!r.error) setTemplates(r.data?.templates || []);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const id = value.whatsappTemplateId;
|
|
if (!id) {
|
|
setDetail(null);
|
|
setHeaderRows([]);
|
|
setBodyRows([]);
|
|
setUrlRows([]);
|
|
setShowImage(false);
|
|
return;
|
|
}
|
|
void whatsappTemplatesApi.get(String(id)).then((r) => {
|
|
if (!r.data) return;
|
|
setDetail(r.data);
|
|
const vb: WhatsAppVariableBindings = r.data.variableBindings || { header: [], body: [], url: [] };
|
|
setShowImage(hasImageHeader((r.data.components || []) as Record<string, unknown>[]));
|
|
setHeaderRows(parseSlotsFromOverrides((vb.header || []).length, "header", value, vb.header));
|
|
setBodyRows(parseSlotsFromOverrides((vb.body || []).length, "body", value, vb.body));
|
|
setUrlRows(parseSlotsFromOverrides((vb.url || []).length, "url", value, vb.url));
|
|
});
|
|
// Only re-load detail when template id changes; slot edits push via emitOverrides
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [value.whatsappTemplateId]);
|
|
|
|
const emitOverrides = (
|
|
nextHeader: SlotRow[],
|
|
nextBody: SlotRow[],
|
|
nextUrl: SlotRow[],
|
|
partial?: Partial<AttendanceWhatsAppNotifySettings>,
|
|
) => {
|
|
const vb = detail?.variableBindings || { header: [], body: [], url: [] };
|
|
const waOverrides: NonNullable<AttendanceWhatsAppNotifySettings["waOverrides"]> = {};
|
|
const h = buildSectionOverrides(nextHeader, vb.header);
|
|
const b = buildSectionOverrides(nextBody, vb.body);
|
|
const u = buildSectionOverrides(nextUrl, vb.url);
|
|
if (h) waOverrides.header = h;
|
|
if (b) waOverrides.body = b;
|
|
if (u) waOverrides.url = u;
|
|
onChange({
|
|
...value,
|
|
...partial,
|
|
waOverrides: Object.keys(waOverrides).length ? waOverrides : null,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 10,
|
|
padding: 12,
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 8,
|
|
background: "var(--secondary)",
|
|
}}
|
|
>
|
|
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(value.enabled)}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...value,
|
|
enabled: e.target.checked,
|
|
...(e.target.checked
|
|
? {}
|
|
: { whatsappTemplateId: null, waHeaderImageUrl: "", waOverrides: null }),
|
|
})
|
|
}
|
|
/>
|
|
Send WhatsApp template on check-in (once per person)
|
|
</label>
|
|
<p style={{ margin: 0, fontSize: 11, color: "var(--muted)" }}>
|
|
Sent after the first successful check-in. Duplicate check-ins do not resend.
|
|
</p>
|
|
|
|
{value.enabled ? (
|
|
<>
|
|
<label style={{ fontSize: 12, fontWeight: 500 }}>
|
|
Template
|
|
<select
|
|
style={{ ...IS, marginTop: 6 }}
|
|
value={value.whatsappTemplateId || ""}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...value,
|
|
whatsappTemplateId: e.target.value || null,
|
|
waOverrides: null,
|
|
waHeaderImageUrl: "",
|
|
})
|
|
}
|
|
>
|
|
<option value="">Select APPROVED template…</option>
|
|
{templates.map((t) => (
|
|
<option key={t._id} value={t._id}>
|
|
{t.name} ({t.language})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
{showImage ? (
|
|
<label style={{ fontSize: 12, fontWeight: 500 }}>
|
|
Header image URL
|
|
<input
|
|
style={{ ...IS, marginTop: 6 }}
|
|
value={value.waHeaderImageUrl || ""}
|
|
placeholder="https://…"
|
|
onChange={(e) => onChange({ ...value, waHeaderImageUrl: e.target.value })}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{detail ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
<SlotTable
|
|
title="Header variables"
|
|
rows={headerRows}
|
|
defaultBindings={detail.variableBindings?.header || []}
|
|
setRows={(next) => {
|
|
setHeaderRows(next);
|
|
emitOverrides(next, bodyRows, urlRows);
|
|
}}
|
|
/>
|
|
<SlotTable
|
|
title="Body variables"
|
|
rows={bodyRows}
|
|
defaultBindings={detail.variableBindings?.body || []}
|
|
setRows={(next) => {
|
|
setBodyRows(next);
|
|
emitOverrides(headerRows, next, urlRows);
|
|
}}
|
|
/>
|
|
<SlotTable
|
|
title="URL button variables"
|
|
rows={urlRows}
|
|
defaultBindings={detail.variableBindings?.url || []}
|
|
setRows={(next) => {
|
|
setUrlRows(next);
|
|
emitOverrides(headerRows, bodyRows, next);
|
|
}}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|