242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
||
import { uploadMediaFile } from "../../../services/api";
|
||
import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts";
|
||
import { PanelShell, RadioGroup, Label, Field, HelperText, IS, focusIn, focusOut } from "../panels/PanelShell";
|
||
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
|
||
|
||
function toDatetimeLocalValue(d: Date) {
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
export function SendWhatsAppNotificationPanel({
|
||
eventId,
|
||
eventName,
|
||
onClose,
|
||
onSuccess,
|
||
}: {
|
||
eventId: string;
|
||
eventName?: string;
|
||
onClose: () => void;
|
||
onSuccess?: () => void;
|
||
}) {
|
||
const [body, setBody] = useState("");
|
||
const [mediaType, setMediaType] = useState<"none" | "image" | "video">("none");
|
||
const [mediaUrl, setMediaUrl] = useState("");
|
||
const [ctas, setCtas] = useState<WhatsAppCtaItem[]>([]);
|
||
const [when, setWhen] = useState<"now" | "schedule">("now");
|
||
const [scheduleLocal, setScheduleLocal] = useState("");
|
||
const [eligible, setEligible] = useState<number | null>(null);
|
||
const [eligibleError, setEligibleError] = useState("");
|
||
const [uploading, setUploading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState("");
|
||
|
||
const loadEligible = useCallback(async () => {
|
||
setEligibleError("");
|
||
const res = await whatsappBlastsApi.eligibleCount(eventId);
|
||
if (res.error) {
|
||
setEligible(null);
|
||
setEligibleError(res.error);
|
||
return;
|
||
}
|
||
setEligible(res.data?.count ?? 0);
|
||
}, [eventId]);
|
||
|
||
useEffect(() => {
|
||
void loadEligible();
|
||
const t = window.setInterval(() => void loadEligible(), 30_000);
|
||
return () => window.clearInterval(t);
|
||
}, [loadEligible]);
|
||
|
||
const onPickMedia = async (file: File | null) => {
|
||
if (!file) return;
|
||
const mime = String(file.type || "").toLowerCase();
|
||
const nextType = mime.startsWith("video/") ? "video" : mime.startsWith("image/") ? "image" : null;
|
||
if (!nextType) {
|
||
setErr("Attach an image or video file.");
|
||
return;
|
||
}
|
||
setUploading(true);
|
||
setErr("");
|
||
try {
|
||
const up = await uploadMediaFile(file, { eventId, module: "whatsapp/notifications" });
|
||
if (!up.url) {
|
||
setErr("Upload failed");
|
||
return;
|
||
}
|
||
setMediaType(nextType);
|
||
setMediaUrl(up.url);
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : "Upload failed");
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const submit = async () => {
|
||
const text = body.trim();
|
||
let ctasPayload: WhatsAppCtaItem[] = [];
|
||
try {
|
||
ctasPayload = normalizeCtasForSave(ctas);
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : "Invalid buttons");
|
||
return;
|
||
}
|
||
if (ctasPayload.length && !text) {
|
||
setErr("Message text is required when buttons are set.");
|
||
return;
|
||
}
|
||
if (!text && !mediaUrl) {
|
||
setErr("Enter a message or attach media.");
|
||
return;
|
||
}
|
||
let sendAt: string | undefined;
|
||
if (when === "schedule") {
|
||
if (!scheduleLocal) {
|
||
setErr("Pick a date and time.");
|
||
return;
|
||
}
|
||
const d = new Date(scheduleLocal);
|
||
if (Number.isNaN(d.getTime())) {
|
||
setErr("Invalid schedule time.");
|
||
return;
|
||
}
|
||
sendAt = d.toISOString();
|
||
}
|
||
setSaving(true);
|
||
setErr("");
|
||
const res = await whatsappBlastsApi.create(eventId, {
|
||
body: text,
|
||
mediaType: mediaUrl ? mediaType : "none",
|
||
mediaUrl: mediaUrl || "",
|
||
ctas: ctasPayload,
|
||
...(sendAt ? { sendAt } : {}),
|
||
});
|
||
setSaving(false);
|
||
if (res.error) {
|
||
setErr(res.error);
|
||
return;
|
||
}
|
||
onSuccess?.();
|
||
onClose();
|
||
};
|
||
|
||
const eligibleLine =
|
||
eligibleError
|
||
? eligibleError
|
||
: eligible == null
|
||
? "Counting people in the 24-hour WhatsApp window…"
|
||
: `${eligible} ${eligible === 1 ? "person" : "people"} on this event can receive this now (open WhatsApp chat, last 24 hours).`;
|
||
|
||
return (
|
||
<PanelShell
|
||
onClose={onClose}
|
||
title="Send WhatsApp notification"
|
||
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · WhatsApp Notifications"}
|
||
description="Sends the same message to everyone on this event with an open WhatsApp chat still inside Meta’s 24-hour window."
|
||
completedCount={1}
|
||
totalSections={1}
|
||
ctaLabel={saving ? "Sending…" : when === "schedule" ? "Schedule" : "Send now"}
|
||
ctaDisabled={saving || uploading}
|
||
onCtaClick={() => void submit()}
|
||
showProgress={false}
|
||
panelWidth="min(560px, 100vw)"
|
||
>
|
||
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--card-foreground)" }}>{eligibleLine}</p>
|
||
|
||
<Field>
|
||
<Label text="Message" />
|
||
<textarea
|
||
value={body}
|
||
onChange={(e) => setBody(e.target.value)}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
rows={6}
|
||
placeholder="Write the WhatsApp message…"
|
||
style={{ ...IS, minHeight: 120, resize: "vertical" }}
|
||
/>
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label text="Image or video (optional)" />
|
||
{mediaUrl ? (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ fontSize: "var(--text-sm)", color: "var(--primary)" }}>
|
||
{mediaType === "video" ? "Video attached" : "Image attached"}
|
||
</a>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaUrl("");
|
||
setMediaType("none");
|
||
}}
|
||
style={{
|
||
fontSize: "var(--text-xs)",
|
||
fontFamily: "var(--font-noto-sans)",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--secondary)",
|
||
borderRadius: "var(--radius-button)",
|
||
padding: "4px 8px",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<input
|
||
type="file"
|
||
accept="image/*,video/*"
|
||
disabled={uploading}
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0] || null;
|
||
e.target.value = "";
|
||
void onPickMedia(file);
|
||
}}
|
||
/>
|
||
)}
|
||
<HelperText text={uploading ? "Uploading…" : "Optional. Recipients outside the 24-hour window are skipped."} />
|
||
</Field>
|
||
|
||
<Field>
|
||
<WhatsAppCtaEditor value={ctas} onChange={setCtas} />
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label text="When" />
|
||
<RadioGroup
|
||
name="wa-blast-when"
|
||
value={when}
|
||
onChange={(v) => setWhen(v === "schedule" ? "schedule" : "now")}
|
||
options={[
|
||
{ label: "Send now", value: "now" },
|
||
{ label: "Schedule", value: "schedule" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
{when === "schedule" ? (
|
||
<Field>
|
||
<Label text="Schedule for" required />
|
||
<input
|
||
type="datetime-local"
|
||
value={scheduleLocal}
|
||
min={toDatetimeLocalValue(new Date())}
|
||
onChange={(e) => setScheduleLocal(e.target.value)}
|
||
onFocus={focusIn}
|
||
onBlur={focusOut}
|
||
style={IS}
|
||
/>
|
||
</Field>
|
||
) : null}
|
||
|
||
{err ? (
|
||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{err}</p>
|
||
) : null}
|
||
</div>
|
||
</PanelShell>
|
||
);
|
||
}
|