More management tools
Some checks failed
CI / Test (1.22) (push) Failing after -30m28s
CI / Lint (push) Failing after -30m32s
CI / Build (push) Failing after -30m31s
CI / Test (1.23) (push) Failing after -30m31s

This commit is contained in:
2026-03-04 22:30:40 +02:00
parent 4a716bb82d
commit 4b44340c58
25 changed files with 3094 additions and 230 deletions

View 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>
);
}