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

3
.gitignore vendored
View File

@@ -49,4 +49,5 @@ Thumbs.db
/server
server.log
/data/*
/data/*
cmd/server/__debug*

View File

@@ -536,25 +536,41 @@ func handleQueryUpdate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
return
}
// Convert data map to model
dataJSON, err := json.Marshal(req.Data)
if err != nil {
http.Error(w, "Invalid data", http.StatusBadRequest)
if req.Data == nil || len(req.Data) == 0 {
http.Error(w, "No update data provided", http.StatusBadRequest)
return
}
if err := json.Unmarshal(dataJSON, model); err != nil {
http.Error(w, "Invalid data format", http.StatusBadRequest)
updateQuery := db.NewUpdate().Model(model).Where("id = ?", req.ID)
updatedColumns := 0
for column, value := range req.Data {
// Protect immutable/audit columns from accidental overwrite.
if column == "id" || column == "created_at" {
continue
}
updateQuery = updateQuery.Set("? = ?", bun.Ident(column), value)
updatedColumns++
}
if updatedColumns == 0 {
http.Error(w, "No mutable fields to update", http.StatusBadRequest)
return
}
// Update in database
_, err = db.NewUpdate().Model(model).Where("id = ?", req.ID).Exec(r.Context())
// Update only the provided fields.
_, err := updateQuery.Exec(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("Update failed: %v", err), http.StatusInternalServerError)
return
}
// Return the latest database row after update.
if err := db.NewSelect().Model(model).Where("id = ?", req.ID).Scan(r.Context()); err != nil {
http.Error(w, fmt.Sprintf("Update succeeded but reload failed: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, model)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/ui/assets/index-Cj4Q_Qgu.js"></script>
<script type="module" crossorigin src="/ui/assets/index-_R1QOTag.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
</head>
<body>

View File

@@ -12,8 +12,9 @@ import (
// ListCatalogs returns all product catalogs linked to the business account.
func (c *Client) ListCatalogs(ctx context.Context) (*CatalogListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
wabaID, err := c.resolveWABAID(ctx)
if err != nil {
return nil, err
}
params := url.Values{
@@ -21,7 +22,7 @@ func (c *Client) ListCatalogs(ctx context.Context) (*CatalogListResponse, error)
}
var resp CatalogListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/catalogs", params, &resp); err != nil {
if err := c.graphAPIGet(ctx, wabaID+"/product_catalogs", params, &resp); err != nil {
return nil, err
}
return &resp, nil

View File

@@ -19,6 +19,8 @@ import (
"go.mau.fi/whatsmeow/types"
)
const defaultBusinessAPIMediaTimeout = 5 * time.Minute
// Client represents a WhatsApp Business API client
type Client struct {
id string
@@ -61,7 +63,7 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
phoneNumber: cfg.PhoneNumber,
config: *cfg.BusinessAPI,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Timeout: defaultBusinessAPIMediaTimeout,
},
eventBus: eventBus,
mediaConfig: mediaConfig,

View File

@@ -7,8 +7,9 @@ import (
// ListFlows returns all flows for the business account.
func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
wabaID, err := c.resolveWABAID(ctx)
if err != nil {
return nil, err
}
params := url.Values{
@@ -16,7 +17,7 @@ func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
}
var resp FlowListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/flows", params, &resp); err != nil {
if err := c.graphAPIGet(ctx, wabaID+"/flows", params, &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -24,12 +25,13 @@ func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
// CreateFlow creates a new flow and returns its ID.
func (c *Client) CreateFlow(ctx context.Context, flow FlowCreateRequest) (*FlowCreateResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
wabaID, err := c.resolveWABAID(ctx)
if err != nil {
return nil, err
}
var resp FlowCreateResponse
if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/flows", flow, &resp); err != nil {
if err := c.graphAPIPost(ctx, wabaID+"/flows", flow, &resp); err != nil {
return nil, err
}
return &resp, nil

View File

@@ -8,6 +8,8 @@ import (
"io"
"mime/multipart"
"net/http"
"net/textproto"
"strings"
)
// UploadMedia uploads a media file to Meta and returns the media ID.
@@ -26,8 +28,16 @@ func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string)
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
// Add the file
part, err := writer.CreateFormFile("file", "media")
if strings.TrimSpace(mimeType) == "" {
mimeType = "application/octet-stream"
}
// Add the file with explicit MIME type so Meta does not treat it as octet-stream.
fileHeader := make(textproto.MIMEHeader)
fileHeader.Set("Content-Disposition", `form-data; name="file"; filename="media"`)
fileHeader.Set("Content-Type", mimeType)
part, err := writer.CreatePart(fileHeader)
if err != nil {
return "", fmt.Errorf("failed to create form file: %w", err)
}

View File

@@ -2,22 +2,43 @@ package businessapi
import (
"context"
"fmt"
"net/url"
)
func (c *Client) resolveWABAID(ctx context.Context) (string, error) {
if c.wabaID != "" {
return c.wabaID, nil
}
if c.config.WABAId != "" {
c.wabaID = c.config.WABAId
return c.wabaID, nil
}
id, err := c.fetchWABAID(ctx)
if err != nil {
return "", fmt.Errorf("could not resolve WABA ID: %w", err)
}
c.wabaID = id
return c.wabaID, nil
}
// ListTemplates returns all message templates for the business account.
// Requires BusinessAccountID in the client config.
// Uses the WhatsApp Business Account (WABA) ID.
func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
wabaID, err := c.resolveWABAID(ctx)
if err != nil {
return nil, err
}
params := url.Values{
"fields": {"id,name,status,language,category,created_at,components,rejection_reasons,quality_score"},
"fields": {"id,name,status,language,category,created_at,components,quality_score"},
}
var resp TemplateListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/message_templates", params, &resp); err != nil {
if err := c.graphAPIGet(ctx, wabaID+"/message_templates", params, &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -25,12 +46,13 @@ func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, erro
// UploadTemplate creates a new message template.
func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest) (*TemplateUploadResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
wabaID, err := c.resolveWABAID(ctx)
if err != nil {
return nil, err
}
var resp TemplateUploadResponse
if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/message_templates", tmpl, &resp); err != nil {
if err := c.graphAPIPost(ctx, wabaID+"/message_templates", tmpl, &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -38,13 +60,14 @@ func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest)
// DeleteTemplate deletes a template by name and language.
func (c *Client) DeleteTemplate(ctx context.Context, name, language string) error {
if c.config.BusinessAccountID == "" {
return errNoBusinessAccount
wabaID, err := c.resolveWABAID(ctx)
if err != nil {
return err
}
params := url.Values{
"name": {name},
"language": {language},
}
return c.graphAPIDelete(ctx, c.config.BusinessAccountID+"/message_templates", params)
return c.graphAPIDelete(ctx, wabaID+"/message_templates", params)
}

View File

@@ -622,7 +622,7 @@ type TemplateInfo struct {
CreatedAt string `json:"created_at"`
Components []TemplateComponentDef `json:"components"`
RejectionReasons []string `json:"rejection_reasons,omitempty"`
QualityScore string `json:"quality_score,omitempty"`
QualityScore any `json:"quality_score,omitempty"`
}
type TemplateComponentDef struct {

41
web/pnpm-lock.yaml generated
View File

@@ -33,8 +33,11 @@ importers:
specifier: ^5.90.20
version: 5.90.21(react@19.2.4)
'@warkypublic/oranguru':
specifier: git+https://git.warky.dev/wdevs/oranguru.git
version: git+https://git.warky.dev/wdevs/oranguru.git#93568891cd3aeede6e8963acaa4e9c30625cf79a(9af3bbb64f08d8812182bee18165aab1)
specifier: ^0.0.49
version: 0.0.49(6f7d9d041a30b18da2b7c3e122a724b4)
'@warkypublic/resolvespec-js':
specifier: ^1.0.1
version: 1.0.1
axios:
specifier: ^1.13.4
version: 1.13.5
@@ -707,23 +710,12 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-virtual@3.13.18':
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.18':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
@@ -825,9 +817,8 @@ packages:
resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==}
engines: {node: '>=14.16'}
'@warkypublic/oranguru@git+https://git.warky.dev/wdevs/oranguru.git#93568891cd3aeede6e8963acaa4e9c30625cf79a':
resolution: {commit: 93568891cd3aeede6e8963acaa4e9c30625cf79a, repo: https://git.warky.dev/wdevs/oranguru.git, type: git}
version: 0.0.49
'@warkypublic/oranguru@0.0.49':
resolution: {integrity: sha512-M//yXt2s1VsbCFC+mexriM0mZxMt6qiOZbmHuQSPjUo7twqjb2eu6bvCXb9iGw/p598hofbi1uEJxjhpzL+mGg==}
peerDependencies:
'@glideapps/glide-data-grid': ^6.0.3
'@mantine/core': ^8.3.1
@@ -836,10 +827,8 @@ packages:
'@mantine/notifications': ^8.3.5
'@tabler/icons-react': ^3.35.0
'@tanstack/react-query': ^5.90.5
'@tanstack/react-table': ^8.21.3
'@warkypublic/artemis-kit': ^1.0.10
'@warkypublic/resolvespec-js': ^1.0.1
'@warkypublic/zustandsyncstore': ^1.0.0
'@warkypublic/zustandsyncstore': ^0.0.4
idb-keyval: ^6.2.2
immer: ^10.1.3
react: '>= 19.0.0'
@@ -2547,20 +2536,12 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.2.4
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.13.18
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.13.18': {}
'@types/babel__core@7.20.5':
@@ -2708,23 +2689,19 @@ snapshots:
semver: 7.7.4
uuid: 11.1.0
'@warkypublic/oranguru@git+https://git.warky.dev/wdevs/oranguru.git#93568891cd3aeede6e8963acaa4e9c30625cf79a(9af3bbb64f08d8812182bee18165aab1)':
'@warkypublic/oranguru@0.0.49(6f7d9d041a30b18da2b7c3e122a724b4)':
dependencies:
'@glideapps/glide-data-grid': 6.0.3(lodash@4.17.23)(marked@4.3.0)(react-dom@19.2.4(react@19.2.4))(react-responsive-carousel@3.2.23)(react@19.2.4)
'@mantine/core': 8.3.15(@mantine/hooks@8.3.15(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/dates': 8.3.15(@mantine/core@8.3.15(@mantine/hooks@8.3.15(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.15(react@19.2.4))(dayjs@1.11.19)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.15(react@19.2.4)
'@mantine/modals': 8.3.15(@mantine/core@8.3.15(@mantine/hooks@8.3.15(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.15(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/notifications': 8.3.15(@mantine/core@8.3.15(@mantine/hooks@8.3.15(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.15(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6)
'@tabler/icons-react': 3.37.1(react@19.2.4)
'@tanstack/react-query': 5.90.21(react@19.2.4)
'@tanstack/react-table': 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/react-virtual': 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@warkypublic/artemis-kit': 1.0.10
'@warkypublic/resolvespec-js': 1.0.1
'@warkypublic/zustandsyncstore': 1.0.0(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(zustand@5.0.11(@types/react@19.2.14)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))
dayjs: 1.11.19
idb-keyval: 6.2.2
immer: 10.2.0
moment: 2.30.1

View File

@@ -11,6 +11,11 @@ import UsersPage from './pages/UsersPage';
import HooksPage from './pages/HooksPage';
import AccountsPage from './pages/AccountsPage';
import EventLogsPage from './pages/EventLogsPage';
import SendMessagePage from './pages/SendMessagePage';
import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage';
import TemplateManagementPage from './pages/TemplateManagementPage';
import CatalogManagementPage from './pages/CatalogManagementPage';
import FlowManagementPage from './pages/FlowManagementPage';
// Import Mantine styles
import '@mantine/core/styles.css';
@@ -44,6 +49,11 @@ function App() {
<Route path="users" element={<UsersPage />} />
<Route path="hooks" element={<HooksPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="whatsapp-business" element={<WhatsAppBusinessPage />} />
<Route path="business-templates" element={<TemplateManagementPage />} />
<Route path="catalogs" element={<CatalogManagementPage />} />
<Route path="flows" element={<FlowManagementPage />} />
<Route path="send-message" element={<SendMessagePage />} />
<Route path="event-logs" element={<EventLogsPage />} />
</Route>

View File

@@ -1,15 +1,30 @@
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { AppShell, Burger, Group, Text, NavLink, Button, Avatar, Stack, Badge } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconDashboard,
IconUsers,
IconWebhook,
IconBrandWhatsapp,
IconFileText,
IconLogout
} from '@tabler/icons-react';
import { useAuthStore } from '../stores/authStore';
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import {
AppShell,
Burger,
Group,
Text,
NavLink,
Button,
Avatar,
Stack,
Badge,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconDashboard,
IconUsers,
IconWebhook,
IconBrandWhatsapp,
IconSend,
IconBuildingStore,
IconTemplate,
IconCategory,
IconArrowsShuffle,
IconFileText,
IconLogout,
} from "@tabler/icons-react";
import { useAuthStore } from "../stores/authStore";
export default function DashboardLayout() {
const { user, logout } = useAuthStore();
@@ -19,27 +34,19 @@ export default function DashboardLayout() {
const handleLogout = () => {
logout();
navigate('/login');
navigate("/login");
};
const isActive = (path: string) => {
return location.pathname === path;
};
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: IconDashboard },
{ path: '/users', label: 'Users', icon: IconUsers },
{ path: '/hooks', label: 'Hooks', icon: IconWebhook },
{ path: '/accounts', label: 'WhatsApp Accounts', icon: IconBrandWhatsapp },
{ path: '/event-logs', label: 'Event Logs', icon: IconFileText },
];
const isActive = (path: string) => location.pathname === path;
const isAnyActive = (paths: string[]) =>
paths.some((path) => location.pathname === path);
return (
<AppShell
header={{ height: 60 }}
navbar={{
width: 280,
breakpoint: 'sm',
breakpoint: "sm",
collapsed: { mobile: !opened },
}}
padding="md"
@@ -47,14 +54,25 @@ export default function DashboardLayout() {
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Text size="xl" fw={700}>WhatsHooked</Text>
<Badge color="blue" variant="light">Admin</Badge>
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<Text size="xl" fw={700}>
WhatsHooked
</Text>
<Badge color="blue" variant="light">
Admin
</Badge>
</Group>
<Group>
<Text size="sm" c="dimmed">{user?.username || 'User'}</Text>
<Text size="sm" c="dimmed">
{user?.username || "User"}
</Text>
<Avatar color="blue" radius="xl" size="sm">
{user?.username?.[0]?.toUpperCase() || 'U'}
{user?.username?.[0]?.toUpperCase() || "U"}
</Avatar>
</Group>
</Group>
@@ -63,20 +81,147 @@ export default function DashboardLayout() {
<AppShell.Navbar p="md">
<AppShell.Section grow>
<Stack gap="xs">
{navItems.map((item) => (
<NavLink
href="/dashboard"
label="Dashboard"
leftSection={<IconDashboard size={20} stroke={1.5} />}
active={isActive("/dashboard")}
onClick={(e) => {
e.preventDefault();
navigate("/dashboard");
if (opened) toggle();
}}
/>
<NavLink
href="/users"
label="Users"
leftSection={<IconUsers size={20} stroke={1.5} />}
active={isActive("/users")}
onClick={(e) => {
e.preventDefault();
navigate("/users");
if (opened) toggle();
}}
/>
<NavLink
href="/hooks"
label="Hooks"
leftSection={<IconWebhook size={20} stroke={1.5} />}
active={isActive("/hooks")}
onClick={(e) => {
e.preventDefault();
navigate("/hooks");
if (opened) toggle();
}}
/>
<NavLink
label="WhatsApp Accounts"
leftSection={<IconBrandWhatsapp size={20} stroke={1.5} />}
defaultOpened
active={isAnyActive([
"/accounts",
"/whatsapp-business",
"/business-templates",
"/catalogs",
"/flows",
])}
>
<NavLink
key={item.path}
href={item.path}
label={item.label}
leftSection={<item.icon size={20} stroke={1.5} />}
active={isActive(item.path)}
href="/accounts"
label="Account List"
active={isActive("/accounts")}
leftSection={
<IconBrandWhatsapp size={20} stroke={1.5} color="green" />
}
onClick={(e) => {
e.preventDefault();
navigate(item.path);
navigate("/accounts");
if (opened) toggle();
}}
/>
))}
<NavLink
label="Business Management"
leftSection={
<IconBrandWhatsapp size={20} stroke={1.5} color="orange" />
}
defaultOpened
active={isAnyActive([
"/whatsapp-business",
"/business-templates",
"/catalogs",
"/flows",
])}
>
<NavLink
href="/whatsapp-business"
label="Business Management Tools"
active={isActive("/whatsapp-business")}
leftSection={<IconBuildingStore size={16} stroke={1.5} />}
onClick={(e) => {
e.preventDefault();
navigate("/whatsapp-business");
if (opened) toggle();
}}
/>
<NavLink
href="/business-templates"
label="Templates"
leftSection={<IconTemplate size={16} stroke={1.5} />}
active={isActive("/business-templates")}
onClick={(e) => {
e.preventDefault();
navigate("/business-templates");
if (opened) toggle();
}}
/>
<NavLink
href="/catalogs"
label="Catalogs"
leftSection={<IconCategory size={16} stroke={1.5} />}
active={isActive("/catalogs")}
onClick={(e) => {
e.preventDefault();
navigate("/catalogs");
if (opened) toggle();
}}
/>
<NavLink
href="/flows"
label="Flows"
leftSection={<IconArrowsShuffle size={16} stroke={1.5} />}
active={isActive("/flows")}
onClick={(e) => {
e.preventDefault();
navigate("/flows");
if (opened) toggle();
}}
/>
</NavLink>
</NavLink>
<NavLink
href="/send-message"
label="Send Message"
leftSection={<IconSend size={20} stroke={1.5} color="green" />}
active={isActive("/send-message")}
onClick={(e) => {
e.preventDefault();
navigate("/send-message");
if (opened) toggle();
}}
/>
<NavLink
href="/event-logs"
label="Event Logs"
leftSection={
<IconFileText size={20} stroke={1.5} color="maroon" />
}
active={isActive("/event-logs")}
onClick={(e) => {
e.preventDefault();
navigate("/event-logs");
if (opened) toggle();
}}
/>
</Stack>
</AppShell.Section>
@@ -84,8 +229,12 @@ export default function DashboardLayout() {
<Stack gap="xs">
<Group justify="space-between" px="sm">
<div>
<Text size="sm" fw={500}>{user?.username || 'User'}</Text>
<Text size="xs" c="dimmed">{user?.role || 'user'}</Text>
<Text size="sm" fw={500}>
{user?.username || "User"}
</Text>
<Text size="xs" c="dimmed">
{user?.role || "user"}
</Text>
</div>
</Group>
<Button

View File

@@ -3,6 +3,14 @@ import type {
User,
Hook,
WhatsAppAccount,
WhatsAppAccountConfig,
PhoneNumberListResponse,
BusinessProfile,
BusinessProfileUpdateRequest,
TemplateListResponse,
TemplateUploadRequest,
FlowListResponse,
CatalogListResponse,
EventLog,
APIKey,
LoginRequest,
@@ -200,6 +208,155 @@ class ApiClient {
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
}
async getAccountConfigs(): Promise<WhatsAppAccountConfig[]> {
const { data } = await this.client.get<WhatsAppAccountConfig[]>("/api/accounts");
return data;
}
async addAccountConfig(
account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> {
const { data } = await this.client.post<{ status: string; account_id: string }>(
"/api/accounts/add",
account,
);
return data;
}
async updateAccountConfig(
account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> {
const { data } = await this.client.post<{ status: string; account_id: string }>(
"/api/accounts/update",
account,
);
return data;
}
async removeAccountConfig(id: string): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>("/api/accounts/remove", { id });
return data;
}
async sendPayload(
endpoint: string,
payload: Record<string, unknown>,
): Promise<{ status?: string; [key: string]: unknown }> {
const { data } = await this.client.post<{ status?: string; [key: string]: unknown }>(
endpoint,
payload,
);
return data;
}
// WhatsApp Business management API
async listPhoneNumbers(accountId: string): Promise<PhoneNumberListResponse> {
const { data } = await this.client.post<PhoneNumberListResponse>(
"/api/phone-numbers",
{ account_id: accountId },
);
return data;
}
async requestVerificationCode(payload: {
account_id: string;
phone_number_id: string;
code_method: "SMS" | "VOICE";
language?: string;
}): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>(
"/api/phone-numbers/request-code",
payload,
);
return data;
}
async verifyPhoneCode(payload: {
account_id: string;
phone_number_id: string;
code: string;
}): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>(
"/api/phone-numbers/verify-code",
payload,
);
return data;
}
async registerPhoneNumber(payload: {
account_id: string;
phone_number_id: string;
pin: string;
}): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>(
"/api/phone-numbers/register",
payload,
);
return data;
}
async getBusinessProfile(accountId: string): Promise<BusinessProfile> {
const { data } = await this.client.post<BusinessProfile>(
"/api/business-profile",
{ account_id: accountId },
);
return data;
}
async updateBusinessProfile(
payload: BusinessProfileUpdateRequest,
): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>(
"/api/business-profile/update",
payload,
);
return data;
}
async listTemplates(accountId: string): Promise<TemplateListResponse> {
const { data } = await this.client.post<TemplateListResponse>(
"/api/templates",
{ account_id: accountId },
);
return data;
}
async uploadTemplate(payload: TemplateUploadRequest): Promise<Record<string, unknown>> {
const { data } = await this.client.post<Record<string, unknown>>(
"/api/templates/upload",
payload,
);
return data;
}
async deleteTemplate(payload: {
account_id: string;
name: string;
language: string;
}): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>(
"/api/templates/delete",
payload,
);
return data;
}
async listCatalogs(accountId: string): Promise<CatalogListResponse> {
const { data } = await this.client.post<CatalogListResponse>(
"/api/catalogs",
{ account_id: accountId },
);
return data;
}
async listFlows(accountId: string): Promise<FlowListResponse> {
const { data } = await this.client.post<FlowListResponse>(
"/api/flows",
{ account_id: accountId },
);
return data;
}
// Event Logs API — uses RestHeadSpec native headers for server-side pagination/sorting
async getEventLogs(params?: {
limit?: number;

View File

@@ -21,8 +21,64 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
import type { WhatsAppAccount } from '../types';
import { apiClient } from '../lib/api';
import type { WhatsAppAccount, WhatsAppAccountConfig } from '../types';
function buildSessionPath(accountId: string) {
return `./sessions/${accountId}`;
}
function toPrettyJSON(value: unknown) {
return JSON.stringify(value, null, 2);
}
function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] {
return [...accounts].sort((a, b) =>
(a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }),
);
}
function mergeAccounts(
configuredAccounts: WhatsAppAccountConfig[],
databaseAccounts: WhatsAppAccount[],
): WhatsAppAccount[] {
const databaseAccountsById = new Map(
databaseAccounts.map((account) => [account.id, account]),
);
const mergedAccounts = configuredAccounts.map((configuredAccount) => {
const databaseAccount = databaseAccountsById.get(configuredAccount.id);
return {
...databaseAccount,
id: configuredAccount.id,
account_id: configuredAccount.id,
user_id: databaseAccount?.user_id || '',
phone_number: configuredAccount.phone_number || databaseAccount?.phone_number || '',
display_name: databaseAccount?.display_name || '',
account_type: configuredAccount.type || databaseAccount?.account_type || 'whatsmeow',
status: databaseAccount?.status || 'disconnected',
config: configuredAccount.business_api
? toPrettyJSON(configuredAccount.business_api)
: (databaseAccount?.config || ''),
session_path: configuredAccount.session_path || databaseAccount?.session_path || buildSessionPath(configuredAccount.id),
last_connected_at: databaseAccount?.last_connected_at,
active: !configuredAccount.disabled,
created_at: databaseAccount?.created_at || '',
updated_at: databaseAccount?.updated_at || '',
};
});
const configuredIds = new Set(configuredAccounts.map((account) => account.id));
const orphanedDatabaseAccounts = databaseAccounts
.filter((account) => !configuredIds.has(account.id))
.map((account) => ({
...account,
account_id: account.account_id || account.id,
}));
return sortAccountsAlphabetically([...mergedAccounts, ...orphanedDatabaseAccounts]);
}
export default function AccountsPage() {
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([]);
@@ -46,8 +102,11 @@ export default function AccountsPage() {
const loadAccounts = async () => {
try {
setLoading(true);
const data = await listRecords<WhatsAppAccount>('whatsapp_accounts');
setAccounts(data || []);
const [configuredAccounts, databaseAccounts] = await Promise.all([
apiClient.getAccountConfigs(),
apiClient.getAccounts(),
]);
setAccounts(mergeAccounts(configuredAccounts || [], databaseAccounts || []));
setError(null);
} catch (err) {
setError('Failed to load accounts');
@@ -73,7 +132,7 @@ export default function AccountsPage() {
const handleEdit = (account: WhatsAppAccount) => {
setEditingAccount(account);
setFormData({
account_id: account.account_id || '',
account_id: account.account_id || account.id || '',
phone_number: account.phone_number,
display_name: account.display_name || '',
account_type: account.account_type,
@@ -88,7 +147,7 @@ export default function AccountsPage() {
return;
}
try {
await deleteRecord('whatsapp_accounts', id);
await apiClient.removeAccountConfig(id);
notifications.show({
title: 'Success',
message: 'Account deleted successfully',
@@ -107,31 +166,47 @@ export default function AccountsPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate config JSON if not empty
if (formData.config) {
const accountId = (editingAccount?.id || formData.account_id).trim();
const parsedConfig = formData.config ? (() => {
try {
JSON.parse(formData.config);
return JSON.parse(formData.config);
} catch {
notifications.show({
title: 'Error',
message: 'Config must be valid JSON',
color: 'red',
});
return;
return null;
}
})() : null;
if (formData.config && parsedConfig === null) {
notifications.show({
title: 'Error',
message: 'Config must be valid JSON',
color: 'red',
});
return;
}
try {
const payload: WhatsAppAccountConfig = {
id: accountId,
type: formData.account_type,
phone_number: formData.phone_number.trim(),
session_path: editingAccount?.session_path || buildSessionPath(accountId),
disabled: !formData.active,
};
if (formData.account_type === 'business-api' && parsedConfig) {
payload.business_api = parsedConfig;
}
if (editingAccount) {
await updateRecord('whatsapp_accounts', editingAccount.id, formData);
await apiClient.updateAccountConfig(payload);
notifications.show({
title: 'Success',
message: 'Account updated successfully',
color: 'green',
});
} else {
await createRecord('whatsapp_accounts', formData);
await apiClient.addAccountConfig(payload);
notifications.show({
title: 'Success',
message: 'Account created successfully',
@@ -281,7 +356,12 @@ export default function AccountsPage() {
value={formData.account_id}
onChange={(e) => setFormData({ ...formData, account_id: e.target.value })}
required
description="Unique identifier for this account (lowercase, alphanumeric, hyphens allowed)"
disabled={!!editingAccount}
description={
editingAccount
? 'Account ID is fixed after creation'
: 'Unique identifier for this account (lowercase, alphanumeric, hyphens allowed)'
}
/>
<TextInput
@@ -309,6 +389,7 @@ export default function AccountsPage() {
{ value: 'business-api', label: 'Business API' }
]}
required
disabled={!!editingAccount}
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
/>

View File

@@ -0,0 +1,223 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Code,
Container,
Group,
Paper,
Select,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconCategory } from "@tabler/icons-react";
import { AxiosError } from "axios";
import { apiClient } from "../lib/api";
import type { CatalogInfo, WhatsAppAccountConfig } from "../types";
function toPrettyJSON(value: unknown): string {
return JSON.stringify(value, null, 2);
}
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 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";
}
export default function CatalogManagementPage() {
const [accounts, setAccounts] = useState<WhatsAppAccountConfig[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(true);
const [accountId, setAccountId] = useState("");
const [catalogs, setCatalogs] = useState<CatalogInfo[]>([]);
const [loading, setLoading] = useState(false);
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)`,
}));
useEffect(() => {
const loadAccounts = async () => {
try {
setLoadingAccounts(true);
const result = await apiClient.getAccountConfigs();
setAccounts(result || []);
} catch {
notifications.show({ title: "Error", message: "Failed to load accounts", color: "red" });
} 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(`[Catalogs] ${entry.title} success`, entry.payload);
} else {
console.error(`[Catalogs] ${entry.title} error`, entry.payload);
}
};
const handleListCatalogs = async () => {
if (!accountId) {
notifications.show({ title: "Validation Error", message: "Select a business account", color: "red" });
return;
}
try {
setLoading(true);
const response = await apiClient.listCatalogs(accountId);
setCatalogs(response.data || []);
appendResponse({ title: "List Catalogs", status: "success", payload: response });
notifications.show({ title: "Success", message: `Loaded ${response.data?.length || 0} catalogs`, color: "green" });
} catch (error) {
appendResponse({ title: "List Catalogs", status: "error", payload: extractErrorPayload(error) });
notifications.show({ title: "Request Failed", message: formatError(error), color: "red" });
} finally {
setLoading(false);
}
};
return (
<Container size="xl" py="xl">
<Stack gap="lg">
<div>
<Title order={2}>Catalog Management</Title>
<Text c="dimmed" size="sm">
List catalogs for a selected WhatsApp Business account.
</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.
</Alert>
)}
<Button onClick={handleListCatalogs} loading={loading} disabled={!accountId}>
List Catalogs
</Button>
</Stack>
</Paper>
<Paper withBorder p="md">
<Stack>
<Text fw={600}>Catalogs</Text>
<Table withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Product Count</Table.Th>
<Table.Th>Catalog ID</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{catalogs.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Group justify="center" py="md">
<IconCategory size={20} />
<Text c="dimmed">No catalogs loaded yet.</Text>
</Group>
</Table.Td>
</Table.Tr>
) : (
catalogs.map((catalog) => (
<Table.Tr key={catalog.id}>
<Table.Td>{catalog.name || "-"}</Table.Td>
<Table.Td>{catalog.product_count ?? "-"}</Table.Td>
<Table.Td>
<Code>{catalog.id}</Code>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</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) => (
<Paper key={entry.id} withBorder p="md">
<Text fw={600} size="sm" mb="xs">
{`${entry.title} - ${entry.status.toUpperCase()} - ${new Date(entry.createdAt).toLocaleString()}`}
</Text>
<Code block>{toPrettyJSON(entry.payload)}</Code>
</Paper>
))}
</Stack>
)}
</Stack>
</Container>
);
}

View File

@@ -77,19 +77,35 @@ export default function DashboardPage() {
const loadStats = async () => {
try {
setLoading(true);
const [users, hooks, accounts, eventLogs] = await Promise.all([
const [usersResult, hooksResult, accountsResult, eventLogsResult] = await Promise.allSettled([
apiClient.getUsers(),
apiClient.getHooks(),
apiClient.getAccounts(),
apiClient.getEventLogs({ limit: 1000, offset: 0 })
apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' })
]);
const users = usersResult.status === 'fulfilled' ? usersResult.value : [];
const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : [];
const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : [];
const eventLogs = eventLogsResult.status === 'fulfilled' ? eventLogsResult.value : null;
const eventLogCount = eventLogs?.meta?.total ?? eventLogs?.data?.length ?? 0;
setStats({
users: users?.length || 0,
hooks: hooks?.length || 0,
accounts: accounts?.length || 0,
eventLogs: eventLogs?.meta?.total || 0
eventLogs: eventLogCount
});
if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected') {
console.error('One or more dashboard stats failed to load', {
users: usersResult.status === 'rejected' ? usersResult.reason : null,
hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null,
accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null,
eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null,
});
}
} catch (err) {
console.error('Failed to load stats:', err);
} finally {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Container,
Title,
@@ -11,10 +11,11 @@ import {
Center,
Stack,
TextInput,
Pagination,
Modal,
Code,
Tooltip
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
import { apiClient } from '../lib/api';
import type { EventLog } from '../types';
@@ -24,11 +25,17 @@ const ITEMS_PER_PAGE = 20;
export default function EventLogsPage() {
const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [totalCount, setTotalCount] = useState<number | null>(null);
const [modalTitle, setModalTitle] = useState('Event Details');
const [modalContent, setModalContent] = useState('');
const [dataModalOpened, { open: openDataModal, close: closeDataModal }] = useDisclosure(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Debounce search input by 400ms
useEffect(() => {
@@ -36,36 +43,95 @@ export default function EventLogsPage() {
return () => clearTimeout(timer);
}, [searchQuery]);
// Reset to page 1 on new search
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearch]);
const loadLogs = useCallback(async () => {
const loadLogsPage = useCallback(async (targetOffset: number, reset: boolean) => {
try {
setLoading(true);
if (reset) {
setLoading(true);
} else {
setLoadingMore(true);
}
const result = await apiClient.getEventLogs({
sort: '-created_at',
limit: ITEMS_PER_PAGE,
offset: (currentPage - 1) * ITEMS_PER_PAGE,
offset: targetOffset,
search: debouncedSearch || undefined,
});
setLogs(result.data || []);
setTotalCount(result.meta?.total || 0);
const pageData = result.data || [];
const hasKnownTotal = typeof result.meta?.total === 'number';
const total = hasKnownTotal ? (result.meta?.total as number) : null;
const nextOffset = targetOffset + pageData.length;
if (reset) {
setLogs(pageData);
} else {
setLogs((previousLogs) => [...previousLogs, ...pageData]);
}
setOffset(nextOffset);
if (total !== null) {
setTotalCount(total);
setHasMore(nextOffset < total);
} else {
setTotalCount(nextOffset);
setHasMore(pageData.length === ITEMS_PER_PAGE);
}
setError(null);
} catch (err) {
setError('Failed to load event logs');
console.error(err);
} finally {
setLoading(false);
if (reset) {
setLoading(false);
} else {
setLoadingMore(false);
}
}
}, [currentPage, debouncedSearch]);
}, [debouncedSearch]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
setLogs([]);
setOffset(0);
setHasMore(false);
setTotalCount(null);
loadLogsPage(0, true);
}, [debouncedSearch, loadLogsPage]);
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
useEffect(() => {
if (!sentinelRef.current || loading || loadingMore || !hasMore || error) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
loadLogsPage(offset, false);
}
},
{ rootMargin: '250px' },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [loading, loadingMore, hasMore, error, offset, loadLogsPage]);
const openDetailsModal = (title: string, content?: string) => {
setModalTitle(title);
setModalContent(getFormattedData(content));
openDataModal();
};
const getFormattedData = (data?: string) => {
if (!data) {
return '';
}
try {
return JSON.stringify(JSON.parse(data), null, 2);
} catch {
return data;
}
};
if (loading) {
return (
@@ -83,6 +149,7 @@ export default function EventLogsPage() {
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
<Text size="sm" c="dimmed">Try adjusting filters or refreshing the page.</Text>
</Container>
);
}
@@ -163,13 +230,24 @@ export default function EventLogsPage() {
</Table.Td>
<Table.Td>
{log.error ? (
<Tooltip label={log.error} position="left" multiline w={300}>
<Code color="red" style={{ cursor: 'help' }}>Error</Code>
<Tooltip label="Click to view error details" position="left">
<Code
component="button"
color="red"
onClick={() => openDetailsModal(`Event Error: ${log.event_type}`, log.error)}
style={{ cursor: 'pointer', border: 'none' }}
>
Error
</Code>
</Tooltip>
) : log.data ? (
<Tooltip label={log.data} position="left" multiline w={300}>
<Code style={{ cursor: 'help' }}>View Data</Code>
</Tooltip>
<Code
component="button"
onClick={() => openDetailsModal(`Event Data: ${log.event_type}`, log.data)}
style={{ cursor: 'pointer', border: 'none' }}
>
View Data
</Code>
) : (
<Text size="sm" c="dimmed">-</Text>
)}
@@ -181,20 +259,44 @@ export default function EventLogsPage() {
</Table.Tbody>
</Table>
{totalPages > 1 && (
<Group justify="center" mt="xl">
<Pagination total={totalPages} value={currentPage} onChange={setCurrentPage} />
</Group>
<div ref={sentinelRef} />
{loadingMore && (
<Center mt="lg">
<Loader size="sm" />
</Center>
)}
<Group justify="space-between" mt="md">
<Text size="sm" c="dimmed">
Showing {logs.length} of {totalCount} logs
{totalCount !== null
? `Showing ${logs.length} of ${totalCount} logs`
: `Showing ${logs.length} logs`}
</Text>
{debouncedSearch && (
<Text size="sm" c="dimmed">Filtered by: "{debouncedSearch}"</Text>
)}
</Group>
<Modal
opened={dataModalOpened}
onClose={closeDataModal}
title={modalTitle}
fullScreen
>
<Code
component="pre"
block
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '90vh',
overflow: 'auto',
}}
>
{modalContent}
</Code>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Code,
Container,
Group,
Paper,
Select,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconArrowsShuffle } from "@tabler/icons-react";
import { AxiosError } from "axios";
import { apiClient } from "../lib/api";
import type { FlowInfo, WhatsAppAccountConfig } from "../types";
function toPrettyJSON(value: unknown): string {
return JSON.stringify(value, null, 2);
}
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 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";
}
export default function FlowManagementPage() {
const [accounts, setAccounts] = useState<WhatsAppAccountConfig[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(true);
const [accountId, setAccountId] = useState("");
const [flows, setFlows] = useState<FlowInfo[]>([]);
const [loading, setLoading] = useState(false);
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)`,
}));
useEffect(() => {
const loadAccounts = async () => {
try {
setLoadingAccounts(true);
const result = await apiClient.getAccountConfigs();
setAccounts(result || []);
} catch {
notifications.show({ title: "Error", message: "Failed to load accounts", color: "red" });
} 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(`[Flows] ${entry.title} success`, entry.payload);
} else {
console.error(`[Flows] ${entry.title} error`, entry.payload);
}
};
const handleListFlows = async () => {
if (!accountId) {
notifications.show({ title: "Validation Error", message: "Select a business account", color: "red" });
return;
}
try {
setLoading(true);
const response = await apiClient.listFlows(accountId);
setFlows(response.data || []);
appendResponse({ title: "List Flows", status: "success", payload: response });
notifications.show({ title: "Success", message: `Loaded ${response.data?.length || 0} flows`, color: "green" });
} catch (error) {
appendResponse({ title: "List Flows", status: "error", payload: extractErrorPayload(error) });
notifications.show({ title: "Request Failed", message: formatError(error), color: "red" });
} finally {
setLoading(false);
}
};
return (
<Container size="xl" py="xl">
<Stack gap="lg">
<div>
<Title order={2}>Flow Management</Title>
<Text c="dimmed" size="sm">
List flows for a selected WhatsApp Business account.
</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.
</Alert>
)}
<Button onClick={handleListFlows} loading={loading} disabled={!accountId}>
List Flows
</Button>
</Stack>
</Paper>
<Paper withBorder p="md">
<Stack>
<Text fw={600}>Flows</Text>
<Table withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Categories</Table.Th>
<Table.Th>Flow ID</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{flows.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Group justify="center" py="md">
<IconArrowsShuffle size={20} />
<Text c="dimmed">No flows loaded yet.</Text>
</Group>
</Table.Td>
</Table.Tr>
) : (
flows.map((flow) => (
<Table.Tr key={flow.id}>
<Table.Td>{flow.name || "-"}</Table.Td>
<Table.Td>{flow.status || "-"}</Table.Td>
<Table.Td>{(flow.categories || []).join(", ") || "-"}</Table.Td>
<Table.Td>
<Code>{flow.id}</Code>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</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) => (
<Paper key={entry.id} withBorder p="md">
<Text fw={600} size="sm" mb="xs">
{`${entry.title} - ${entry.status.toUpperCase()} - ${new Date(entry.createdAt).toLocaleString()}`}
</Text>
<Code block>{toPrettyJSON(entry.payload)}</Code>
</Paper>
))}
</Stack>
)}
</Stack>
</Container>
);
}

View File

@@ -27,6 +27,12 @@ import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconWebhook } from '@ta
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
import type { Hook } from '../types';
function sortHooksAlphabetically(hooks: Hook[]): Hook[] {
return [...hooks].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
);
}
export default function HooksPage() {
const [hooks, setHooks] = useState<Hook[]>([]);
const [loading, setLoading] = useState(true);
@@ -54,7 +60,7 @@ export default function HooksPage() {
try {
setLoading(true);
const data = await listRecords<Hook>('hooks');
setHooks(data || []);
setHooks(sortHooksAlphabetically(data || []));
setError(null);
} catch (err) {
setError('Failed to load hooks');

View File

@@ -0,0 +1,732 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Code,
Container,
FileInput,
Group,
NumberInput,
Paper,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconAlertCircle, IconSend } from '@tabler/icons-react';
import { apiClient } from '../lib/api';
import type { WhatsAppAccountConfig } from '../types';
type MessageType =
| 'text'
| 'image'
| 'video'
| 'document'
| 'audio'
| 'sticker'
| 'location'
| 'reaction'
| 'contacts'
| 'interactive'
| 'template'
| 'flow'
| 'catalog'
| 'product'
| 'product-list';
const MESSAGE_TYPES: { value: MessageType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'image', label: 'Image' },
{ value: 'video', label: 'Video' },
{ value: 'document', label: 'Document' },
{ value: 'audio', label: 'Audio' },
{ value: 'sticker', label: 'Sticker' },
{ value: 'location', label: 'Location' },
{ value: 'reaction', label: 'Reaction' },
{ value: 'contacts', label: 'Contacts (JSON)' },
{ value: 'interactive', label: 'Interactive (JSON)' },
{ value: 'template', label: 'Template (JSON)' },
{ value: 'flow', label: 'Flow' },
{ value: 'catalog', label: 'Catalog' },
{ value: 'product', label: 'Single Product' },
{ value: 'product-list', label: 'Product List' },
];
function isBlank(value: string): boolean {
return value.trim().length === 0;
}
function normalizeBase64(value: string): string {
const trimmed = value.trim();
const commaIndex = trimmed.indexOf(',');
if (trimmed.startsWith('data:') && commaIndex > -1) {
return trimmed.slice(commaIndex + 1).trim();
}
return trimmed;
}
function extractMimeTypeFromDataURL(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith('data:')) {
return '';
}
const semicolonIndex = trimmed.indexOf(';');
const colonIndex = trimmed.indexOf(':');
if (semicolonIndex <= colonIndex) {
return '';
}
return trimmed.slice(colonIndex + 1, semicolonIndex).trim();
}
function isLikelyBase64(value: string): boolean {
if (!value) {
return false;
}
const compact = value.replace(/\s+/g, '');
if (compact.length === 0 || compact.length % 4 !== 0) {
return false;
}
return /^[A-Za-z0-9+/=]+$/.test(compact);
}
function inferMediaMimeType(
explicitMimeType: string,
sourceValue: string,
messageType: MessageType,
): string {
const trimmed = explicitMimeType.trim();
if (trimmed) {
return trimmed;
}
const inferredFromDataURL = extractMimeTypeFromDataURL(sourceValue);
if (inferredFromDataURL) {
return inferredFromDataURL;
}
switch (messageType) {
case 'image':
return 'image/jpeg';
case 'video':
return 'video/mp4';
case 'document':
return 'application/octet-stream';
case 'audio':
return 'audio/mpeg';
case 'sticker':
return 'image/webp';
default:
return '';
}
}
function parseJSONField<T>(raw: string, label: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`${label} must be valid JSON`);
}
}
function getEndpoint(type: MessageType): string {
switch (type) {
case 'text':
return '/api/send';
case 'image':
return '/api/send/image';
case 'video':
return '/api/send/video';
case 'document':
return '/api/send/document';
case 'audio':
return '/api/send/audio';
case 'sticker':
return '/api/send/sticker';
case 'location':
return '/api/send/location';
case 'reaction':
return '/api/send/reaction';
case 'contacts':
return '/api/send/contacts';
case 'interactive':
return '/api/send/interactive';
case 'template':
return '/api/send/template';
case 'flow':
return '/api/send/flow';
case 'catalog':
return '/api/send/catalog';
case 'product':
return '/api/send/product';
case 'product-list':
return '/api/send/product-list';
default:
return '/api/send';
}
}
export default function SendMessagePage() {
const [accounts, setAccounts] = useState<WhatsAppAccountConfig[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(true);
const [accountId, setAccountId] = useState('');
const [to, setTo] = useState('');
const [messageType, setMessageType] = useState<MessageType>('text');
const [submitting, setSubmitting] = useState(false);
const [text, setText] = useState('');
const [caption, setCaption] = useState('');
const [mimeType, setMimeType] = useState('');
const [mediaBase64, setMediaBase64] = useState('');
const [filename, setFilename] = useState('');
const [latitude, setLatitude] = useState<number | ''>('');
const [longitude, setLongitude] = useState<number | ''>('');
const [locationName, setLocationName] = useState('');
const [locationAddress, setLocationAddress] = useState('');
const [messageId, setMessageId] = useState('');
const [emoji, setEmoji] = useState('');
const [rawJSON, setRawJSON] = useState('');
const [flowId, setFlowId] = useState('');
const [flowToken, setFlowToken] = useState('');
const [flowScreenName, setFlowScreenName] = useState('');
const [flowHeader, setFlowHeader] = useState('');
const [flowBody, setFlowBody] = useState('');
const [flowFooter, setFlowFooter] = useState('');
const [catalogId, setCatalogId] = useState('');
const [bodyText, setBodyText] = useState('');
const [footerText, setFooterText] = useState('');
const [headerText, setHeaderText] = useState('');
const [productRetailerId, setProductRetailerId] = useState('');
const [thumbnailProductRetailerId, setThumbnailProductRetailerId] = useState('');
const [lastApiResponse, setLastApiResponse] = useState('');
const [lastApiResponseType, setLastApiResponseType] = useState<'success' | 'error' | null>(null);
const validateBeforeSend = (): string | null => {
if (!accountId) {
return 'Please select an account.';
}
if (isBlank(to)) {
return 'Recipient is required.';
}
if (to.trim().length < 5) {
return 'Recipient number looks too short.';
}
if (messageType === 'text' && isBlank(text)) {
return 'Text message cannot be empty.';
}
if (
messageType === 'image' ||
messageType === 'video' ||
messageType === 'document' ||
messageType === 'audio' ||
messageType === 'sticker'
) {
const normalized = normalizeBase64(mediaBase64);
if (!normalized) {
return 'Base64 data is required for this message type.';
}
if (!isLikelyBase64(normalized)) {
return 'Base64 data is invalid. Paste only valid base64 (raw or data URL).';
}
if (normalized.length < 64) {
return 'Base64 data looks too short or incomplete.';
}
}
if (messageType === 'location') {
if (latitude === '' || longitude === '') {
return 'Latitude and longitude are required.';
}
if (Number(latitude) < -90 || Number(latitude) > 90) {
return 'Latitude must be between -90 and 90.';
}
if (Number(longitude) < -180 || Number(longitude) > 180) {
return 'Longitude must be between -180 and 180.';
}
}
if (messageType === 'reaction') {
if (isBlank(messageId)) {
return 'Message ID is required for reactions.';
}
if (isBlank(emoji)) {
return 'Emoji is required for reactions.';
}
}
if (messageType === 'contacts') {
const contacts = parseJSONField<unknown[]>(rawJSON, 'Contacts');
if (!Array.isArray(contacts) || contacts.length === 0) {
return 'Contacts JSON must be a non-empty array.';
}
}
if (messageType === 'interactive') {
const interactive = parseJSONField<Record<string, unknown>>(rawJSON, 'Interactive payload');
if (!interactive || Array.isArray(interactive)) {
return 'Interactive payload must be a JSON object.';
}
}
if (messageType === 'template') {
const template = parseJSONField<{ name?: string }>(rawJSON, 'Template payload');
if (!template || Array.isArray(template) || isBlank(template.name || '')) {
return 'Template JSON must include a non-empty "name".';
}
}
if (messageType === 'flow') {
if (isBlank(flowId)) {
return 'Flow ID is required.';
}
if (isBlank(flowBody)) {
return 'Flow body is required.';
}
if (!isBlank(rawJSON)) {
const flowData = parseJSONField<Record<string, unknown>>(
rawJSON,
'Flow data',
);
if (!flowData || Array.isArray(flowData)) {
return 'Flow data must be a JSON object.';
}
}
}
if (messageType === 'catalog' && isBlank(bodyText)) {
return 'Body text is required for catalog messages.';
}
if (messageType === 'product') {
if (isBlank(catalogId)) {
return 'Catalog ID is required for product messages.';
}
if (isBlank(productRetailerId)) {
return 'Product Retailer ID is required.';
}
if (isBlank(bodyText)) {
return 'Body text is required for product messages.';
}
}
if (messageType === 'product-list') {
if (isBlank(catalogId)) {
return 'Catalog ID is required for product lists.';
}
if (isBlank(headerText) || isBlank(bodyText)) {
return 'Header text and body text are required for product lists.';
}
const sections = parseJSONField<unknown[]>(rawJSON, 'Sections');
if (!Array.isArray(sections) || sections.length === 0) {
return 'Sections JSON must be a non-empty array.';
}
}
return null;
};
useEffect(() => {
const loadAccounts = async () => {
try {
setLoadingAccounts(true);
const result = await apiClient.getAccountConfigs();
const sorted = [...(result || [])].sort((a, b) =>
a.id.localeCompare(b.id, undefined, { sensitivity: 'base' }),
);
setAccounts(sorted);
if (sorted.length > 0) {
setAccountId(sorted[0].id);
}
} catch (err) {
notifications.show({
title: 'Error',
message: 'Failed to load accounts',
color: 'red',
});
console.error(err);
} finally {
setLoadingAccounts(false);
}
};
loadAccounts();
}, []);
const readFileAsDataURL = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
const handleMediaFileUpload = async (file: File | null) => {
if (!file) {
return;
}
try {
const dataURL = await readFileAsDataURL(file);
setMediaBase64(dataURL);
if (isBlank(mimeType) && file.type) {
setMimeType(file.type);
}
if (messageType === 'document' && isBlank(filename)) {
setFilename(file.name);
}
notifications.show({
title: 'File Loaded',
message: `Loaded ${file.name} into Base64 data`,
color: 'green',
});
} catch (err) {
notifications.show({
title: 'File Error',
message: err instanceof Error ? err.message : 'Failed to read file',
color: 'red',
});
}
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
let validationError: string | null = null;
try {
validationError = validateBeforeSend();
} catch (err) {
validationError = err instanceof Error ? err.message : 'Validation failed.';
}
if (validationError) {
notifications.show({
title: 'Validation Error',
message: validationError,
color: 'red',
});
return;
}
try {
const payload: Record<string, unknown> = { account_id: accountId, to };
const endpoint = getEndpoint(messageType);
switch (messageType) {
case 'text':
payload.text = text;
break;
case 'image':
payload.image_data = normalizeBase64(mediaBase64);
payload.caption = caption.trim();
payload.mime_type = inferMediaMimeType(mimeType, mediaBase64, messageType);
break;
case 'video':
payload.video_data = normalizeBase64(mediaBase64);
payload.caption = caption.trim();
payload.mime_type = inferMediaMimeType(mimeType, mediaBase64, messageType);
break;
case 'document':
payload.document_data = normalizeBase64(mediaBase64);
payload.caption = caption.trim();
payload.mime_type = inferMediaMimeType(mimeType, mediaBase64, messageType);
payload.filename = filename.trim();
break;
case 'audio':
payload.audio_data = normalizeBase64(mediaBase64);
payload.mime_type = inferMediaMimeType(mimeType, mediaBase64, messageType);
break;
case 'sticker':
payload.sticker_data = normalizeBase64(mediaBase64);
payload.mime_type = inferMediaMimeType(mimeType, mediaBase64, messageType);
break;
case 'location':
payload.latitude = Number(latitude);
payload.longitude = Number(longitude);
payload.name = locationName.trim();
payload.address = locationAddress.trim();
break;
case 'reaction':
payload.message_id = messageId.trim();
payload.emoji = emoji.trim();
break;
case 'contacts':
payload.contacts = parseJSONField<unknown[]>(rawJSON, 'Contacts');
break;
case 'interactive':
payload.interactive = parseJSONField<Record<string, unknown>>(rawJSON, 'Interactive payload');
break;
case 'template':
payload.template = parseJSONField<Record<string, unknown>>(rawJSON, 'Template payload');
break;
case 'flow':
payload.flow_id = flowId.trim();
payload.flow_token = flowToken.trim();
payload.screen_name = flowScreenName.trim();
payload.header = flowHeader.trim();
payload.body = flowBody.trim();
payload.footer = flowFooter.trim();
payload.data = rawJSON ? parseJSONField<Record<string, unknown>>(rawJSON, 'Flow data') : {};
break;
case 'catalog':
payload.body_text = bodyText.trim();
payload.thumbnail_product_retailer_id = thumbnailProductRetailerId.trim();
break;
case 'product':
payload.catalog_id = catalogId.trim();
payload.product_retailer_id = productRetailerId.trim();
payload.body_text = bodyText.trim();
payload.footer_text = footerText.trim();
break;
case 'product-list':
payload.catalog_id = catalogId.trim();
payload.header_text = headerText.trim();
payload.body_text = bodyText.trim();
payload.footer_text = footerText.trim();
payload.sections = parseJSONField<unknown[]>(rawJSON, 'Sections');
break;
}
setSubmitting(true);
const response = await apiClient.sendPayload(endpoint, payload);
const responseText = JSON.stringify(response, null, 2);
setLastApiResponse(responseText);
setLastApiResponseType('success');
console.log('Send API response', { endpoint, response });
notifications.show({
title: 'Success',
message: 'Message sent successfully',
color: 'green',
});
} catch (err) {
const errorData = (err as { response?: { data?: unknown } })?.response?.data;
const responseText = JSON.stringify(
{
error: err instanceof Error ? err.message : 'Unknown error',
response: errorData ?? null,
},
null,
2,
);
setLastApiResponse(responseText);
setLastApiResponseType('error');
console.error('Send API response (error)', err);
notifications.show({
title: 'Send Failed',
message: err instanceof Error ? err.message : 'Failed to send message',
color: 'red',
});
console.error(err);
} finally {
setSubmitting(false);
}
};
const accountOptions = accounts.map((account) => ({
value: account.id,
label: `${account.id} (${account.type})`,
}));
return (
<Container size="lg" py="xl">
<Stack gap="lg">
<div>
<Title order={2}>Send Message</Title>
<Text c="dimmed" size="sm">Choose an account, recipient, and message type to send WhatsApp messages.</Text>
</div>
{accounts.length === 0 && !loadingAccounts && (
<Alert icon={<IconAlertCircle size={16} />} color="yellow" title="No Accounts">
No WhatsApp accounts are configured yet.
</Alert>
)}
<Paper withBorder p="md">
<form onSubmit={handleSend}>
<Stack>
<Group grow>
<Select
label="Account"
data={accountOptions}
value={accountId}
onChange={(value) => setAccountId(value || '')}
searchable
required
disabled={loadingAccounts}
/>
<TextInput
label="Recipient"
placeholder="+1234567890"
value={to}
onChange={(e) => setTo(e.target.value)}
required
/>
</Group>
<Select
label="Message Type"
data={MESSAGE_TYPES}
value={messageType}
onChange={(value) => setMessageType((value as MessageType) || 'text')}
required
/>
{messageType === 'text' && (
<Textarea label="Text" value={text} onChange={(e) => setText(e.target.value)} minRows={3} required />
)}
{(messageType === 'image' || messageType === 'video' || messageType === 'document' || messageType === 'audio' || messageType === 'sticker') && (
<>
<FileInput
label="Upload File"
placeholder="Choose a file to populate Base64 data"
onChange={handleMediaFileUpload}
clearable
/>
<Textarea
label="Base64 Data"
value={mediaBase64}
onChange={(e) => setMediaBase64(e.target.value)}
minRows={6}
required
/>
{(messageType === 'image' || messageType === 'video' || messageType === 'document') && (
<TextInput label="Caption" value={caption} onChange={(e) => setCaption(e.target.value)} />
)}
<TextInput label="MIME Type" value={mimeType} onChange={(e) => setMimeType(e.target.value)} />
{messageType === 'document' && (
<TextInput label="Filename" value={filename} onChange={(e) => setFilename(e.target.value)} />
)}
</>
)}
{messageType === 'location' && (
<Group grow>
<NumberInput
label="Latitude"
value={latitude}
onChange={(value) => setLatitude(typeof value === 'number' ? value : '')}
required
/>
<NumberInput
label="Longitude"
value={longitude}
onChange={(value) => setLongitude(typeof value === 'number' ? value : '')}
required
/>
<TextInput label="Name" value={locationName} onChange={(e) => setLocationName(e.target.value)} />
<TextInput label="Address" value={locationAddress} onChange={(e) => setLocationAddress(e.target.value)} />
</Group>
)}
{messageType === 'reaction' && (
<Group grow>
<TextInput label="Message ID" value={messageId} onChange={(e) => setMessageId(e.target.value)} required />
<TextInput label="Emoji" value={emoji} onChange={(e) => setEmoji(e.target.value)} required />
</Group>
)}
{messageType === 'flow' && (
<>
<Group grow>
<TextInput label="Flow ID" value={flowId} onChange={(e) => setFlowId(e.target.value)} required />
<TextInput label="Flow Token" value={flowToken} onChange={(e) => setFlowToken(e.target.value)} />
<TextInput label="Screen Name" value={flowScreenName} onChange={(e) => setFlowScreenName(e.target.value)} />
</Group>
<TextInput label="Header" value={flowHeader} onChange={(e) => setFlowHeader(e.target.value)} />
<TextInput label="Body" value={flowBody} onChange={(e) => setFlowBody(e.target.value)} required />
<TextInput label="Footer" value={flowFooter} onChange={(e) => setFlowFooter(e.target.value)} />
<Textarea
label="Flow Data JSON"
value={rawJSON}
onChange={(e) => setRawJSON(e.target.value)}
minRows={4}
placeholder='{"key":"value"}'
/>
</>
)}
{(messageType === 'catalog' || messageType === 'product' || messageType === 'product-list') && (
<>
{messageType !== 'catalog' && (
<TextInput label="Catalog ID" value={catalogId} onChange={(e) => setCatalogId(e.target.value)} required />
)}
{messageType === 'product-list' && (
<TextInput label="Header Text" value={headerText} onChange={(e) => setHeaderText(e.target.value)} required />
)}
<TextInput label="Body Text" value={bodyText} onChange={(e) => setBodyText(e.target.value)} required />
{messageType === 'product' && (
<>
<TextInput label="Product Retailer ID" value={productRetailerId} onChange={(e) => setProductRetailerId(e.target.value)} required />
<TextInput label="Footer Text" value={footerText} onChange={(e) => setFooterText(e.target.value)} />
</>
)}
{messageType === 'catalog' && (
<TextInput
label="Thumbnail Product Retailer ID"
value={thumbnailProductRetailerId}
onChange={(e) => setThumbnailProductRetailerId(e.target.value)}
/>
)}
{messageType === 'product-list' && (
<Textarea
label="Sections JSON"
value={rawJSON}
onChange={(e) => setRawJSON(e.target.value)}
minRows={5}
placeholder='[{"title":"Section 1","product_items":[{"product_retailer_id":"sku-1"}]}]'
required
/>
)}
</>
)}
{(messageType === 'contacts' || messageType === 'interactive' || messageType === 'template') && (
<Textarea
label={`${messageType} JSON`}
value={rawJSON}
onChange={(e) => setRawJSON(e.target.value)}
minRows={6}
required
/>
)}
<Group justify="flex-end">
<Button leftSection={<IconSend size={16} />} type="submit" loading={submitting}>
Send
</Button>
</Group>
</Stack>
</form>
</Paper>
{lastApiResponseType && (
<Paper withBorder p="md">
<Stack gap="xs">
<Text fw={600}>
API Response ({lastApiResponseType === 'success' ? 'Success' : 'Error'})
</Text>
<Code
component="pre"
block
color={lastApiResponseType === 'error' ? 'red' : undefined}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{lastApiResponse}
</Code>
</Stack>
</Paper>
)}
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,372 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Code,
Container,
Group,
Paper,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconFileText } from "@tabler/icons-react";
import { AxiosError } from "axios";
import { apiClient } from "../lib/api";
import type {
TemplateInfo,
TemplateUploadComponent,
TemplateUploadRequest,
WhatsAppAccountConfig,
} from "../types";
function toPrettyJSON(value: unknown): string {
return JSON.stringify(value, null, 2);
}
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 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 parseComponents(input: string): TemplateUploadComponent[] {
const parsed = JSON.parse(input) as unknown;
if (!Array.isArray(parsed)) {
throw new Error("Components must be a JSON array");
}
return parsed as TemplateUploadComponent[];
}
export default function TemplateManagementPage() {
const [accounts, setAccounts] = useState<WhatsAppAccountConfig[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(true);
const [accountId, setAccountId] = useState("");
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
const [uploadName, setUploadName] = useState("");
const [uploadLanguage, setUploadLanguage] = useState("en_US");
const [uploadCategory, setUploadCategory] = useState("UTILITY");
const [uploadComponents, setUploadComponents] = useState(
'[{"type":"BODY","text":"Hello {{1}}"}]',
);
const [deleteName, setDeleteName] = useState("");
const [deleteLanguage, setDeleteLanguage] = useState("en_US");
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],
);
useEffect(() => {
const loadAccounts = async () => {
try {
setLoadingAccounts(true);
const result = await apiClient.getAccountConfigs();
setAccounts(result || []);
} catch (error) {
notifications.show({ title: "Error", message: "Failed to load accounts", color: "red" });
} 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(`[Templates] ${entry.title} success`, entry.payload);
} else {
console.error(`[Templates] ${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", 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" });
return undefined;
} finally {
setActionLoading((prev) => ({ ...prev, [key]: false }));
}
};
const handleListTemplates = async () => {
const result = await runAction("list", "List Templates", async () => apiClient.listTemplates(accountId));
if (result) {
setTemplates(result.data || []);
notifications.show({ title: "Success", message: `Loaded ${result.data?.length || 0} templates`, color: "green" });
}
};
const handleUploadTemplate = async (e: React.FormEvent) => {
e.preventDefault();
let components: TemplateUploadComponent[];
try {
components = parseComponents(uploadComponents);
} catch (error) {
notifications.show({
title: "Validation Error",
message: error instanceof Error ? error.message : "Invalid components JSON",
color: "red",
});
return;
}
const payload: TemplateUploadRequest = {
account_id: accountId,
name: uploadName.trim(),
language: uploadLanguage.trim(),
category: uploadCategory,
components,
};
const result = await runAction("upload", "Upload Template", async () => {
const response = await apiClient.uploadTemplate(payload);
return { payload, response };
});
if (result) {
notifications.show({ title: "Success", message: "Template upload requested", color: "green" });
await handleListTemplates();
}
};
const handleDeleteTemplate = async (e: React.FormEvent) => {
e.preventDefault();
const payload = {
account_id: accountId,
name: deleteName.trim(),
language: deleteLanguage.trim(),
};
const result = await runAction("delete", "Delete Template", async () => {
const response = await apiClient.deleteTemplate(payload);
return { payload, response };
});
if (result) {
notifications.show({ title: "Success", message: "Template deleted", color: "green" });
await handleListTemplates();
}
};
const accountOptions = businessAccounts.map((entry) => ({
value: entry.id,
label: `${entry.id} (business-api)`,
}));
return (
<Container size="xl" py="xl">
<Stack gap="lg">
<div>
<Title order={2}>Business Template Management</Title>
<Text c="dimmed" size="sm">
List, upload, and delete WhatsApp Business templates.
</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.
</Alert>
)}
</Stack>
</Paper>
<Paper withBorder p="md">
<Stack>
<Group justify="space-between">
<Text fw={600}>Templates</Text>
<Button onClick={handleListTemplates} loading={!!actionLoading.list} disabled={!accountId}>
List Templates
</Button>
</Group>
<Table withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Language</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Template ID</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{templates.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center" py="md">
<IconFileText size={20} />
<Text c="dimmed">No templates loaded yet.</Text>
</Group>
</Table.Td>
</Table.Tr>
) : (
templates.map((template) => (
<Table.Tr key={template.id}>
<Table.Td>{template.name}</Table.Td>
<Table.Td>{template.language}</Table.Td>
<Table.Td>{template.category}</Table.Td>
<Table.Td>{template.status}</Table.Td>
<Table.Td>
<Code>{template.id}</Code>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Stack>
</Paper>
<Paper withBorder p="md">
<form onSubmit={handleUploadTemplate}>
<Stack>
<Text fw={600}>Upload Template</Text>
<Group grow>
<TextInput label="Name" value={uploadName} onChange={(e) => setUploadName(e.currentTarget.value)} required />
<TextInput label="Language" value={uploadLanguage} onChange={(e) => setUploadLanguage(e.currentTarget.value)} required />
<Select
label="Category"
value={uploadCategory}
onChange={(value) => setUploadCategory(value || "UTILITY")}
data={[
{ value: "MARKETING", label: "MARKETING" },
{ value: "UTILITY", label: "UTILITY" },
{ value: "AUTHENTICATION", label: "AUTHENTICATION" },
]}
required
/>
</Group>
<Textarea
label="Components JSON"
description="Array of template components"
value={uploadComponents}
onChange={(e) => setUploadComponents(e.currentTarget.value)}
minRows={6}
required
/>
<Button type="submit" loading={!!actionLoading.upload} disabled={!accountId}>
Upload Template
</Button>
</Stack>
</form>
</Paper>
<Paper withBorder p="md">
<form onSubmit={handleDeleteTemplate}>
<Stack>
<Text fw={600}>Delete Template</Text>
<Group grow>
<TextInput label="Template Name" value={deleteName} onChange={(e) => setDeleteName(e.currentTarget.value)} required />
<TextInput
label="Language"
value={deleteLanguage}
onChange={(e) => setDeleteLanguage(e.currentTarget.value)}
required
/>
</Group>
<Button type="submit" color="red" loading={!!actionLoading.delete} disabled={!accountId}>
Delete Template
</Button>
</Stack>
</form>
</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) => (
<Paper key={entry.id} withBorder p="md">
<Text fw={600} size="sm" mb="xs">
{`${entry.title} - ${entry.status.toUpperCase()} - ${new Date(entry.createdAt).toLocaleString()}`}
</Text>
<Code block>{toPrettyJSON(entry.payload)}</Code>
</Paper>
))}
</Stack>
)}
</Stack>
</Container>
);
}

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

View File

@@ -43,6 +43,163 @@ export interface WhatsAppAccount {
updated_at: string;
}
export interface BusinessAPIConfig {
phone_number_id?: string;
access_token?: string;
waba_id?: string;
business_account_id?: string;
api_version?: string;
webhook_path?: string;
verify_token?: string;
[key: string]: unknown;
}
export interface ThroughputInfo {
level?: string;
}
export interface PhoneNumberListItem {
id: string;
display_phone_number: string;
phone_number: string;
verified_name: string;
code_verification_status: string;
quality_rating: string;
throughput?: ThroughputInfo;
}
export interface PhoneNumberListResponse {
data: PhoneNumberListItem[];
paging?: Record<string, unknown>;
}
export interface BusinessProfile {
about?: string;
address?: string;
description?: string;
email?: string;
websites?: string[];
vertical?: string;
profile_picture_url?: string;
}
export interface BusinessProfileUpdateRequest {
account_id: string;
about?: string;
address?: string;
description?: string;
email?: string;
websites?: string[];
vertical?: string;
}
export interface TemplateComponentExample {
header_handle?: string[];
body_example?: string[][];
}
export interface TemplateButtonDef {
type: string;
text: string;
url?: string;
phone_number?: string;
dynamic?: boolean;
}
export interface TemplateComponentDef {
type: string;
format?: string;
text?: string;
buttons?: TemplateButtonDef[];
example?: TemplateComponentExample;
}
export interface TemplateInfo {
id: string;
name: string;
status: string;
language: string;
category: string;
created_at: string;
components: TemplateComponentDef[];
rejection_reasons?: string[];
quality_score?: string;
}
export interface TemplateListResponse {
data: TemplateInfo[];
paging?: Record<string, unknown>;
}
export interface TemplateUploadButton {
type: string;
text: string;
url?: string;
phone_number?: string;
example?: string[];
}
export interface TemplateUploadExample {
header_handle?: string[];
body_example?: string[][];
}
export interface TemplateUploadComponent {
type: string;
format?: string;
text?: string;
buttons?: TemplateUploadButton[];
example?: TemplateUploadExample;
}
export interface TemplateUploadRequest {
account_id: string;
name: string;
language: string;
category: string;
components: TemplateUploadComponent[];
allow_category_change?: boolean;
}
export interface FlowInfo {
id: string;
name: string;
status: string;
categories: string[];
created_at: string;
updated_at: string;
endpoint_url?: string;
preview_url?: string;
signed_preview_url?: string;
signed_flow_url?: string;
}
export interface FlowListResponse {
data: FlowInfo[];
paging?: Record<string, unknown>;
}
export interface CatalogInfo {
id: string;
name: string;
product_count?: number;
}
export interface CatalogListResponse {
data: CatalogInfo[];
paging?: Record<string, unknown>;
}
export interface WhatsAppAccountConfig {
id: string;
type: 'whatsmeow' | 'business-api';
phone_number: string;
session_path?: string;
show_qr?: boolean;
disabled?: boolean;
business_api?: BusinessAPIConfig;
}
export interface EventLog {
id: string;
user_id?: string;