602 lines
20 KiB
TypeScript
602 lines
20 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Code,
|
|
Container,
|
|
Group,
|
|
Paper,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
Textarea,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { notifications } from "@mantine/notifications";
|
|
import { IconAlertCircle, IconBuildingStore, IconPhoneCall } from "@tabler/icons-react";
|
|
import { AxiosError } from "axios";
|
|
import { apiClient } from "../lib/api";
|
|
import type {
|
|
BusinessProfile,
|
|
PhoneNumberListItem,
|
|
WhatsAppAccountConfig,
|
|
} from "../types";
|
|
|
|
function toPrettyJSON(value: unknown): string {
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function formatError(error: unknown): string {
|
|
if (error instanceof AxiosError) {
|
|
if (typeof error.response?.data === "string") return error.response.data;
|
|
if (error.response?.data && typeof error.response.data === "object") {
|
|
return toPrettyJSON(error.response.data);
|
|
}
|
|
return error.message;
|
|
}
|
|
if (error instanceof Error) return error.message;
|
|
return "Request failed";
|
|
}
|
|
|
|
function extractErrorPayload(error: unknown): unknown {
|
|
if (error instanceof AxiosError) {
|
|
return error.response?.data ?? { message: error.message };
|
|
}
|
|
if (error instanceof Error) return { message: error.message };
|
|
return { message: "Request failed" };
|
|
}
|
|
|
|
function websitesToText(websites?: string[]): string {
|
|
return (websites || []).join("\n");
|
|
}
|
|
|
|
function parseWebsitesInput(input: string): string[] {
|
|
return input
|
|
.split(/[\n,]/)
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
}
|
|
|
|
type ResultPanelProps = {
|
|
title: string;
|
|
payload: unknown;
|
|
};
|
|
|
|
function ResultPanel({ title, payload }: ResultPanelProps) {
|
|
return (
|
|
<Paper withBorder p="md">
|
|
<Text fw={600} size="sm" mb="xs">
|
|
{title}
|
|
</Text>
|
|
<Code block>{toPrettyJSON(payload)}</Code>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export default function WhatsAppBusinessPage() {
|
|
const [accounts, setAccounts] = useState<WhatsAppAccountConfig[]>([]);
|
|
const [loadingAccounts, setLoadingAccounts] = useState(true);
|
|
const [accountId, setAccountId] = useState("");
|
|
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberListItem[]>([]);
|
|
|
|
const [phoneNumberIdForCode, setPhoneNumberIdForCode] = useState("");
|
|
const [codeMethod, setCodeMethod] = useState<"SMS" | "VOICE">("SMS");
|
|
const [language, setLanguage] = useState("en_US");
|
|
|
|
const [phoneNumberIdForVerify, setPhoneNumberIdForVerify] = useState("");
|
|
const [verificationCode, setVerificationCode] = useState("");
|
|
|
|
const [phoneNumberIdForRegister, setPhoneNumberIdForRegister] = useState("");
|
|
const [pin, setPin] = useState("");
|
|
|
|
const [businessProfile, setBusinessProfile] = useState<BusinessProfile | null>(null);
|
|
const [about, setAbout] = useState("");
|
|
const [address, setAddress] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [websites, setWebsites] = useState("");
|
|
const [vertical, setVertical] = useState("");
|
|
|
|
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
|
const [responseHistory, setResponseHistory] = useState<
|
|
Array<{
|
|
id: string;
|
|
title: string;
|
|
status: "success" | "error";
|
|
payload: unknown;
|
|
createdAt: string;
|
|
}>
|
|
>([]);
|
|
|
|
const businessAccounts = useMemo(
|
|
() =>
|
|
accounts
|
|
.filter((entry) => entry.type === "business-api" && !entry.disabled)
|
|
.sort((a, b) => a.id.localeCompare(b.id, undefined, { sensitivity: "base" })),
|
|
[accounts],
|
|
);
|
|
|
|
const accountOptions = businessAccounts.map((entry) => ({
|
|
value: entry.id,
|
|
label: `${entry.id} (business-api)`,
|
|
}));
|
|
|
|
const phoneNumberOptions = phoneNumbers.map((entry) => ({
|
|
value: entry.id,
|
|
label: `${entry.display_phone_number || entry.phone_number} (${entry.id})`,
|
|
}));
|
|
|
|
useEffect(() => {
|
|
const loadAccounts = async () => {
|
|
try {
|
|
setLoadingAccounts(true);
|
|
const result = await apiClient.getAccountConfigs();
|
|
setAccounts(result || []);
|
|
} catch (error) {
|
|
notifications.show({
|
|
title: "Error",
|
|
message: "Failed to load WhatsApp accounts",
|
|
color: "red",
|
|
});
|
|
console.error(error);
|
|
} finally {
|
|
setLoadingAccounts(false);
|
|
}
|
|
};
|
|
|
|
loadAccounts();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!accountId && businessAccounts.length > 0) {
|
|
setAccountId(businessAccounts[0].id);
|
|
}
|
|
}, [businessAccounts, accountId]);
|
|
|
|
const appendResponse = (entry: {
|
|
title: string;
|
|
status: "success" | "error";
|
|
payload: unknown;
|
|
}) => {
|
|
const item = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
createdAt: new Date().toISOString(),
|
|
...entry,
|
|
};
|
|
setResponseHistory((prev) => [item, ...prev].slice(0, 20));
|
|
if (entry.status === "success") {
|
|
console.info(`[WhatsApp Business] ${entry.title} success`, entry.payload);
|
|
} else {
|
|
console.error(`[WhatsApp Business] ${entry.title} error`, entry.payload);
|
|
}
|
|
};
|
|
|
|
const runAction = async <T,>(key: string, actionTitle: string, action: () => Promise<T>) => {
|
|
if (!accountId) {
|
|
notifications.show({
|
|
title: "Validation Error",
|
|
message: "Select a business account first",
|
|
color: "red",
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
setActionLoading((prev) => ({ ...prev, [key]: true }));
|
|
const result = await action();
|
|
appendResponse({
|
|
title: actionTitle,
|
|
status: "success",
|
|
payload: result,
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
appendResponse({
|
|
title: actionTitle,
|
|
status: "error",
|
|
payload: extractErrorPayload(error),
|
|
});
|
|
notifications.show({
|
|
title: "Request Failed",
|
|
message: formatError(error),
|
|
color: "red",
|
|
});
|
|
console.error(error);
|
|
return undefined;
|
|
} finally {
|
|
setActionLoading((prev) => ({ ...prev, [key]: false }));
|
|
}
|
|
};
|
|
|
|
const handleListPhoneNumbers = async () => {
|
|
const result = await runAction("listPhoneNumbers", "List Phone Numbers", async () => {
|
|
return apiClient.listPhoneNumbers(accountId);
|
|
});
|
|
|
|
if (result) {
|
|
setPhoneNumbers(result.data || []);
|
|
notifications.show({
|
|
title: "Success",
|
|
message: `Loaded ${result.data?.length || 0} phone number(s)`,
|
|
color: "green",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRequestVerificationCode = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const response = await runAction("requestCode", "Request Verification Code", async () => {
|
|
return apiClient.requestVerificationCode({
|
|
account_id: accountId,
|
|
phone_number_id: phoneNumberIdForCode.trim(),
|
|
code_method: codeMethod,
|
|
language: language.trim() || "en_US",
|
|
});
|
|
});
|
|
|
|
if (response) {
|
|
notifications.show({
|
|
title: "Success",
|
|
message: "Verification code requested",
|
|
color: "green",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleVerifyCode = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const response = await runAction("verifyCode", "Verify Code", async () => {
|
|
return apiClient.verifyPhoneCode({
|
|
account_id: accountId,
|
|
phone_number_id: phoneNumberIdForVerify.trim(),
|
|
code: verificationCode.trim(),
|
|
});
|
|
});
|
|
|
|
if (response) {
|
|
notifications.show({
|
|
title: "Success",
|
|
message: "Verification code accepted",
|
|
color: "green",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRegisterPhoneNumber = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const response = await runAction("registerPhoneNumber", "Register Phone Number", async () => {
|
|
return apiClient.registerPhoneNumber({
|
|
account_id: accountId,
|
|
phone_number_id: phoneNumberIdForRegister.trim(),
|
|
pin: pin.trim(),
|
|
});
|
|
});
|
|
|
|
if (response) {
|
|
notifications.show({
|
|
title: "Success",
|
|
message: "Phone number registration submitted",
|
|
color: "green",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleGetBusinessProfile = async () => {
|
|
const profile = await runAction("getBusinessProfile", "Get Business Profile", async () => {
|
|
return apiClient.getBusinessProfile(accountId);
|
|
});
|
|
|
|
if (profile) {
|
|
setBusinessProfile(profile);
|
|
setAbout(profile.about || "");
|
|
setAddress(profile.address || "");
|
|
setDescription(profile.description || "");
|
|
setEmail(profile.email || "");
|
|
setWebsites(websitesToText(profile.websites));
|
|
setVertical(profile.vertical || "");
|
|
notifications.show({
|
|
title: "Success",
|
|
message: "Business profile loaded",
|
|
color: "green",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdateBusinessProfile = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
const payload = {
|
|
account_id: accountId,
|
|
about: about.trim(),
|
|
address: address.trim(),
|
|
description: description.trim(),
|
|
email: email.trim(),
|
|
websites: parseWebsitesInput(websites),
|
|
vertical: vertical.trim(),
|
|
};
|
|
|
|
const response = await runAction("updateBusinessProfile", "Update Business Profile", async () => {
|
|
const result = await apiClient.updateBusinessProfile(payload);
|
|
return { payload, response: result };
|
|
});
|
|
|
|
if (response) {
|
|
notifications.show({
|
|
title: "Success",
|
|
message: "Business profile updated",
|
|
color: "green",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Container size="xl" py="xl">
|
|
<Stack gap="lg">
|
|
<div>
|
|
<Title order={2}>WhatsApp Business Management</Title>
|
|
<Text c="dimmed" size="sm">
|
|
Select a business account, then manage phone number verification/registration and business profile details.
|
|
</Text>
|
|
</div>
|
|
|
|
<Paper withBorder p="md">
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fw={600}>Business Account</Text>
|
|
<Badge variant="light" color="blue">
|
|
{businessAccounts.length} business account(s)
|
|
</Badge>
|
|
</Group>
|
|
<Select
|
|
label="Account"
|
|
data={accountOptions}
|
|
value={accountId}
|
|
onChange={(value) => setAccountId(value || "")}
|
|
searchable
|
|
disabled={loadingAccounts}
|
|
placeholder="Select an account"
|
|
/>
|
|
{businessAccounts.length === 0 && !loadingAccounts && (
|
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" title="No Business API Accounts">
|
|
No enabled `business-api` accounts were found. Add one in the WhatsApp Accounts page first.
|
|
</Alert>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper withBorder p="md">
|
|
<Stack gap="md">
|
|
<Group>
|
|
<IconPhoneCall size={20} />
|
|
<Text fw={600}>Phone Number Management</Text>
|
|
</Group>
|
|
|
|
<Group>
|
|
<Button
|
|
onClick={handleListPhoneNumbers}
|
|
loading={!!actionLoading.listPhoneNumbers}
|
|
disabled={!accountId}
|
|
>
|
|
List Phone Numbers
|
|
</Button>
|
|
</Group>
|
|
|
|
{phoneNumbers.length > 0 && (
|
|
<Table withTableBorder withColumnBorders>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Display Number</Table.Th>
|
|
<Table.Th>Verified Name</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th>Quality</Table.Th>
|
|
<Table.Th>Throughput</Table.Th>
|
|
<Table.Th>Phone Number ID</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{phoneNumbers.map((entry) => (
|
|
<Table.Tr key={entry.id}>
|
|
<Table.Td>{entry.display_phone_number || "-"}</Table.Td>
|
|
<Table.Td>{entry.verified_name || "-"}</Table.Td>
|
|
<Table.Td>{entry.code_verification_status || "-"}</Table.Td>
|
|
<Table.Td>{entry.quality_rating || "-"}</Table.Td>
|
|
<Table.Td>{entry.throughput?.level || "-"}</Table.Td>
|
|
<Table.Td>
|
|
<Code>{entry.id}</Code>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
|
|
<SimpleGrid cols={{ base: 1, lg: 3 }}>
|
|
<Paper withBorder p="sm">
|
|
<form onSubmit={handleRequestVerificationCode}>
|
|
<Stack>
|
|
<Text fw={600} size="sm">
|
|
Request Verification Code
|
|
</Text>
|
|
<Select
|
|
label="Phone Number ID"
|
|
data={phoneNumberOptions}
|
|
value={phoneNumberIdForCode}
|
|
onChange={(value) => setPhoneNumberIdForCode(value || "")}
|
|
searchable
|
|
clearable
|
|
/>
|
|
<TextInput
|
|
label="Or enter ID manually"
|
|
value={phoneNumberIdForCode}
|
|
onChange={(e) => setPhoneNumberIdForCode(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
<Select
|
|
label="Code Method"
|
|
value={codeMethod}
|
|
onChange={(value) => setCodeMethod((value as "SMS" | "VOICE") || "SMS")}
|
|
data={[
|
|
{ value: "SMS", label: "SMS" },
|
|
{ value: "VOICE", label: "VOICE" },
|
|
]}
|
|
required
|
|
/>
|
|
<TextInput
|
|
label="Language"
|
|
value={language}
|
|
onChange={(e) => setLanguage(e.currentTarget.value)}
|
|
placeholder="en_US"
|
|
/>
|
|
<Button type="submit" loading={!!actionLoading.requestCode} disabled={!accountId}>
|
|
Request Code
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Paper>
|
|
|
|
<Paper withBorder p="sm">
|
|
<form onSubmit={handleVerifyCode}>
|
|
<Stack>
|
|
<Text fw={600} size="sm">
|
|
Verify Code
|
|
</Text>
|
|
<Select
|
|
label="Phone Number ID"
|
|
data={phoneNumberOptions}
|
|
value={phoneNumberIdForVerify}
|
|
onChange={(value) => setPhoneNumberIdForVerify(value || "")}
|
|
searchable
|
|
clearable
|
|
/>
|
|
<TextInput
|
|
label="Or enter ID manually"
|
|
value={phoneNumberIdForVerify}
|
|
onChange={(e) => setPhoneNumberIdForVerify(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
<TextInput
|
|
label="Verification Code"
|
|
value={verificationCode}
|
|
onChange={(e) => setVerificationCode(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
<Button type="submit" loading={!!actionLoading.verifyCode} disabled={!accountId}>
|
|
Verify Code
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Paper>
|
|
|
|
<Paper withBorder p="sm">
|
|
<form onSubmit={handleRegisterPhoneNumber}>
|
|
<Stack>
|
|
<Text fw={600} size="sm">
|
|
Register Phone Number
|
|
</Text>
|
|
<Select
|
|
label="Phone Number ID"
|
|
data={phoneNumberOptions}
|
|
value={phoneNumberIdForRegister}
|
|
onChange={(value) => setPhoneNumberIdForRegister(value || "")}
|
|
searchable
|
|
clearable
|
|
/>
|
|
<TextInput
|
|
label="Or enter ID manually"
|
|
value={phoneNumberIdForRegister}
|
|
onChange={(e) => setPhoneNumberIdForRegister(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
<TextInput
|
|
label="PIN (4-8 digits)"
|
|
value={pin}
|
|
onChange={(e) => setPin(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
<Button type="submit" loading={!!actionLoading.registerPhoneNumber} disabled={!accountId}>
|
|
Register Number
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Paper>
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper withBorder p="md">
|
|
<Stack gap="md">
|
|
<Group>
|
|
<IconBuildingStore size={20} />
|
|
<Text fw={600}>Business Profile</Text>
|
|
</Group>
|
|
|
|
<Group>
|
|
<Button
|
|
variant="light"
|
|
onClick={handleGetBusinessProfile}
|
|
loading={!!actionLoading.getBusinessProfile}
|
|
disabled={!accountId}
|
|
>
|
|
Get Business Profile
|
|
</Button>
|
|
</Group>
|
|
|
|
{businessProfile?.profile_picture_url && (
|
|
<Alert color="blue" title="Profile Picture URL">
|
|
{businessProfile.profile_picture_url}
|
|
</Alert>
|
|
)}
|
|
|
|
<form onSubmit={handleUpdateBusinessProfile}>
|
|
<Stack>
|
|
<TextInput label="About" value={about} onChange={(e) => setAbout(e.currentTarget.value)} />
|
|
<TextInput label="Address" value={address} onChange={(e) => setAddress(e.currentTarget.value)} />
|
|
<Textarea
|
|
label="Description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
|
minRows={3}
|
|
/>
|
|
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
|
|
<Textarea
|
|
label="Websites"
|
|
description="One URL per line or comma-separated"
|
|
value={websites}
|
|
onChange={(e) => setWebsites(e.currentTarget.value)}
|
|
minRows={2}
|
|
/>
|
|
<TextInput label="Vertical" value={vertical} onChange={(e) => setVertical(e.currentTarget.value)} />
|
|
<Button type="submit" loading={!!actionLoading.updateBusinessProfile} disabled={!accountId}>
|
|
Update Business Profile
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{responseHistory.length > 0 && (
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fw={600}>Response History</Text>
|
|
<Button variant="subtle" color="gray" size="xs" onClick={() => setResponseHistory([])}>
|
|
Clear
|
|
</Button>
|
|
</Group>
|
|
{responseHistory.map((entry) => (
|
|
<ResultPanel
|
|
key={entry.id}
|
|
title={`${entry.title} - ${entry.status.toUpperCase()} - ${new Date(entry.createdAt).toLocaleString()}`}
|
|
payload={entry.payload}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
}
|