More management tools
This commit is contained in:
601
web/src/pages/WhatsAppBusinessPage.tsx
Normal file
601
web/src/pages/WhatsAppBusinessPage.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user