More management tools
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ Thumbs.db
|
|||||||
|
|
||||||
server.log
|
server.log
|
||||||
/data/*
|
/data/*
|
||||||
|
cmd/server/__debug*
|
||||||
|
|||||||
@@ -536,25 +536,41 @@ func handleQueryUpdate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert data map to model
|
if req.Data == nil || len(req.Data) == 0 {
|
||||||
dataJSON, err := json.Marshal(req.Data)
|
http.Error(w, "No update data provided", http.StatusBadRequest)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid data", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(dataJSON, model); err != nil {
|
updateQuery := db.NewUpdate().Model(model).Where("id = ?", req.ID)
|
||||||
http.Error(w, "Invalid data format", http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in database
|
// Update only the provided fields.
|
||||||
_, err = db.NewUpdate().Model(model).Where("id = ?", req.ID).Exec(r.Context())
|
_, err := updateQuery.Exec(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Update failed: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Update failed: %v", err), http.StatusInternalServerError)
|
||||||
return
|
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)
|
writeJSON(w, http.StatusOK, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
pkg/serverembed/dist/assets/index-Cj4Q_Qgu.js
vendored
72
pkg/serverembed/dist/assets/index-Cj4Q_Qgu.js
vendored
File diff suppressed because one or more lines are too long
73
pkg/serverembed/dist/assets/index-_R1QOTag.js
vendored
Normal file
73
pkg/serverembed/dist/assets/index-_R1QOTag.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
pkg/serverembed/dist/index.html
vendored
2
pkg/serverembed/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import (
|
|||||||
|
|
||||||
// ListCatalogs returns all product catalogs linked to the business account.
|
// ListCatalogs returns all product catalogs linked to the business account.
|
||||||
func (c *Client) ListCatalogs(ctx context.Context) (*CatalogListResponse, error) {
|
func (c *Client) ListCatalogs(ctx context.Context) (*CatalogListResponse, error) {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID, err := c.resolveWABAID(ctx)
|
||||||
return nil, errNoBusinessAccount
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{
|
params := url.Values{
|
||||||
@@ -21,7 +22,7 @@ func (c *Client) ListCatalogs(ctx context.Context) (*CatalogListResponse, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp CatalogListResponse
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
"go.mau.fi/whatsmeow/types"
|
"go.mau.fi/whatsmeow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultBusinessAPIMediaTimeout = 5 * time.Minute
|
||||||
|
|
||||||
// Client represents a WhatsApp Business API client
|
// Client represents a WhatsApp Business API client
|
||||||
type Client struct {
|
type Client struct {
|
||||||
id string
|
id string
|
||||||
@@ -61,7 +63,7 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
|
|||||||
phoneNumber: cfg.PhoneNumber,
|
phoneNumber: cfg.PhoneNumber,
|
||||||
config: *cfg.BusinessAPI,
|
config: *cfg.BusinessAPI,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: defaultBusinessAPIMediaTimeout,
|
||||||
},
|
},
|
||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
mediaConfig: mediaConfig,
|
mediaConfig: mediaConfig,
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
|
|
||||||
// ListFlows returns all flows for the business account.
|
// ListFlows returns all flows for the business account.
|
||||||
func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
|
func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID, err := c.resolveWABAID(ctx)
|
||||||
return nil, errNoBusinessAccount
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{
|
params := url.Values{
|
||||||
@@ -16,7 +17,7 @@ func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp FlowListResponse
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
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.
|
// CreateFlow creates a new flow and returns its ID.
|
||||||
func (c *Client) CreateFlow(ctx context.Context, flow FlowCreateRequest) (*FlowCreateResponse, error) {
|
func (c *Client) CreateFlow(ctx context.Context, flow FlowCreateRequest) (*FlowCreateResponse, error) {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID, err := c.resolveWABAID(ctx)
|
||||||
return nil, errNoBusinessAccount
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp FlowCreateResponse
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMedia uploads a media file to Meta and returns the media ID.
|
// 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
|
var requestBody bytes.Buffer
|
||||||
writer := multipart.NewWriter(&requestBody)
|
writer := multipart.NewWriter(&requestBody)
|
||||||
|
|
||||||
// Add the file
|
if strings.TrimSpace(mimeType) == "" {
|
||||||
part, err := writer.CreateFormFile("file", "media")
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create form file: %w", err)
|
return "", fmt.Errorf("failed to create form file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,43 @@ package businessapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/url"
|
"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.
|
// 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) {
|
func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, error) {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID, err := c.resolveWABAID(ctx)
|
||||||
return nil, errNoBusinessAccount
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{
|
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
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
@@ -25,12 +46,13 @@ func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, erro
|
|||||||
|
|
||||||
// UploadTemplate creates a new message template.
|
// UploadTemplate creates a new message template.
|
||||||
func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest) (*TemplateUploadResponse, error) {
|
func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest) (*TemplateUploadResponse, error) {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID, err := c.resolveWABAID(ctx)
|
||||||
return nil, errNoBusinessAccount
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp TemplateUploadResponse
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
@@ -38,13 +60,14 @@ func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest)
|
|||||||
|
|
||||||
// DeleteTemplate deletes a template by name and language.
|
// DeleteTemplate deletes a template by name and language.
|
||||||
func (c *Client) DeleteTemplate(ctx context.Context, name, language string) error {
|
func (c *Client) DeleteTemplate(ctx context.Context, name, language string) error {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID, err := c.resolveWABAID(ctx)
|
||||||
return errNoBusinessAccount
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{
|
params := url.Values{
|
||||||
"name": {name},
|
"name": {name},
|
||||||
"language": {language},
|
"language": {language},
|
||||||
}
|
}
|
||||||
return c.graphAPIDelete(ctx, c.config.BusinessAccountID+"/message_templates", params)
|
return c.graphAPIDelete(ctx, wabaID+"/message_templates", params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ type TemplateInfo struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Components []TemplateComponentDef `json:"components"`
|
Components []TemplateComponentDef `json:"components"`
|
||||||
RejectionReasons []string `json:"rejection_reasons,omitempty"`
|
RejectionReasons []string `json:"rejection_reasons,omitempty"`
|
||||||
QualityScore string `json:"quality_score,omitempty"`
|
QualityScore any `json:"quality_score,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateComponentDef struct {
|
type TemplateComponentDef struct {
|
||||||
|
|||||||
41
web/pnpm-lock.yaml
generated
41
web/pnpm-lock.yaml
generated
@@ -33,8 +33,11 @@ importers:
|
|||||||
specifier: ^5.90.20
|
specifier: ^5.90.20
|
||||||
version: 5.90.21(react@19.2.4)
|
version: 5.90.21(react@19.2.4)
|
||||||
'@warkypublic/oranguru':
|
'@warkypublic/oranguru':
|
||||||
specifier: git+https://git.warky.dev/wdevs/oranguru.git
|
specifier: ^0.0.49
|
||||||
version: git+https://git.warky.dev/wdevs/oranguru.git#93568891cd3aeede6e8963acaa4e9c30625cf79a(9af3bbb64f08d8812182bee18165aab1)
|
version: 0.0.49(6f7d9d041a30b18da2b7c3e122a724b4)
|
||||||
|
'@warkypublic/resolvespec-js':
|
||||||
|
specifier: ^1.0.1
|
||||||
|
version: 1.0.1
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.13.4
|
specifier: ^1.13.4
|
||||||
version: 1.13.5
|
version: 1.13.5
|
||||||
@@ -707,23 +710,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
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':
|
'@tanstack/react-virtual@3.13.18':
|
||||||
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
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':
|
'@tanstack/virtual-core@3.13.18':
|
||||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||||
|
|
||||||
@@ -825,9 +817,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==}
|
resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
'@warkypublic/oranguru@git+https://git.warky.dev/wdevs/oranguru.git#93568891cd3aeede6e8963acaa4e9c30625cf79a':
|
'@warkypublic/oranguru@0.0.49':
|
||||||
resolution: {commit: 93568891cd3aeede6e8963acaa4e9c30625cf79a, repo: https://git.warky.dev/wdevs/oranguru.git, type: git}
|
resolution: {integrity: sha512-M//yXt2s1VsbCFC+mexriM0mZxMt6qiOZbmHuQSPjUo7twqjb2eu6bvCXb9iGw/p598hofbi1uEJxjhpzL+mGg==}
|
||||||
version: 0.0.49
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@glideapps/glide-data-grid': ^6.0.3
|
'@glideapps/glide-data-grid': ^6.0.3
|
||||||
'@mantine/core': ^8.3.1
|
'@mantine/core': ^8.3.1
|
||||||
@@ -836,10 +827,8 @@ packages:
|
|||||||
'@mantine/notifications': ^8.3.5
|
'@mantine/notifications': ^8.3.5
|
||||||
'@tabler/icons-react': ^3.35.0
|
'@tabler/icons-react': ^3.35.0
|
||||||
'@tanstack/react-query': ^5.90.5
|
'@tanstack/react-query': ^5.90.5
|
||||||
'@tanstack/react-table': ^8.21.3
|
|
||||||
'@warkypublic/artemis-kit': ^1.0.10
|
'@warkypublic/artemis-kit': ^1.0.10
|
||||||
'@warkypublic/resolvespec-js': ^1.0.1
|
'@warkypublic/zustandsyncstore': ^0.0.4
|
||||||
'@warkypublic/zustandsyncstore': ^1.0.0
|
|
||||||
idb-keyval: ^6.2.2
|
idb-keyval: ^6.2.2
|
||||||
immer: ^10.1.3
|
immer: ^10.1.3
|
||||||
react: '>= 19.0.0'
|
react: '>= 19.0.0'
|
||||||
@@ -2547,20 +2536,12 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.90.20
|
'@tanstack/query-core': 5.90.20
|
||||||
react: 19.2.4
|
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)':
|
'@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.18
|
'@tanstack/virtual-core': 3.13.18
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(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': {}
|
'@tanstack/virtual-core@3.13.18': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
@@ -2708,23 +2689,19 @@ snapshots:
|
|||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
uuid: 11.1.0
|
uuid: 11.1.0
|
||||||
|
|
||||||
'@warkypublic/oranguru@git+https://git.warky.dev/wdevs/oranguru.git#93568891cd3aeede6e8963acaa4e9c30625cf79a(9af3bbb64f08d8812182bee18165aab1)':
|
'@warkypublic/oranguru@0.0.49(6f7d9d041a30b18da2b7c3e122a724b4)':
|
||||||
dependencies:
|
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)
|
'@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/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/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/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)
|
'@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)
|
'@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6)
|
||||||
'@tabler/icons-react': 3.37.1(react@19.2.4)
|
'@tabler/icons-react': 3.37.1(react@19.2.4)
|
||||||
'@tanstack/react-query': 5.90.21(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)
|
'@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/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)))
|
'@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
|
idb-keyval: 6.2.2
|
||||||
immer: 10.2.0
|
immer: 10.2.0
|
||||||
moment: 2.30.1
|
moment: 2.30.1
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import UsersPage from './pages/UsersPage';
|
|||||||
import HooksPage from './pages/HooksPage';
|
import HooksPage from './pages/HooksPage';
|
||||||
import AccountsPage from './pages/AccountsPage';
|
import AccountsPage from './pages/AccountsPage';
|
||||||
import EventLogsPage from './pages/EventLogsPage';
|
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 styles
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
@@ -44,6 +49,11 @@ function App() {
|
|||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="hooks" element={<HooksPage />} />
|
<Route path="hooks" element={<HooksPage />} />
|
||||||
<Route path="accounts" element={<AccountsPage />} />
|
<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 path="event-logs" element={<EventLogsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { AppShell, Burger, Group, Text, NavLink, Button, Avatar, Stack, Badge } from '@mantine/core';
|
import {
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
AppShell,
|
||||||
|
Burger,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
NavLink,
|
||||||
|
Button,
|
||||||
|
Avatar,
|
||||||
|
Stack,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconWebhook,
|
IconWebhook,
|
||||||
IconBrandWhatsapp,
|
IconBrandWhatsapp,
|
||||||
|
IconSend,
|
||||||
|
IconBuildingStore,
|
||||||
|
IconTemplate,
|
||||||
|
IconCategory,
|
||||||
|
IconArrowsShuffle,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconLogout
|
IconLogout,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
export default function DashboardLayout() {
|
export default function DashboardLayout() {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
@@ -19,27 +34,19 @@ export default function DashboardLayout() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => location.pathname === path;
|
||||||
return location.pathname === path;
|
const isAnyActive = (paths: string[]) =>
|
||||||
};
|
paths.some((path) => 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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 60 }}
|
header={{ height: 60 }}
|
||||||
navbar={{
|
navbar={{
|
||||||
width: 280,
|
width: 280,
|
||||||
breakpoint: 'sm',
|
breakpoint: "sm",
|
||||||
collapsed: { mobile: !opened },
|
collapsed: { mobile: !opened },
|
||||||
}}
|
}}
|
||||||
padding="md"
|
padding="md"
|
||||||
@@ -47,14 +54,25 @@ export default function DashboardLayout() {
|
|||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
<Group h="100%" px="md" justify="space-between">
|
<Group h="100%" px="md" justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
<Burger
|
||||||
<Text size="xl" fw={700}>WhatsHooked</Text>
|
opened={opened}
|
||||||
<Badge color="blue" variant="light">Admin</Badge>
|
onClick={toggle}
|
||||||
|
hiddenFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
WhatsHooked
|
||||||
|
</Text>
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<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">
|
<Avatar color="blue" radius="xl" size="sm">
|
||||||
{user?.username?.[0]?.toUpperCase() || 'U'}
|
{user?.username?.[0]?.toUpperCase() || "U"}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -63,20 +81,147 @@ export default function DashboardLayout() {
|
|||||||
<AppShell.Navbar p="md">
|
<AppShell.Navbar p="md">
|
||||||
<AppShell.Section grow>
|
<AppShell.Section grow>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{navItems.map((item) => (
|
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
href="/dashboard"
|
||||||
href={item.path}
|
label="Dashboard"
|
||||||
label={item.label}
|
leftSection={<IconDashboard size={20} stroke={1.5} />}
|
||||||
leftSection={<item.icon size={20} stroke={1.5} />}
|
active={isActive("/dashboard")}
|
||||||
active={isActive(item.path)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(item.path);
|
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
|
||||||
|
href="/accounts"
|
||||||
|
label="Account List"
|
||||||
|
active={isActive("/accounts")}
|
||||||
|
leftSection={
|
||||||
|
<IconBrandWhatsapp size={20} stroke={1.5} color="green" />
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
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();
|
if (opened) toggle();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
|
|
||||||
@@ -84,8 +229,12 @@ export default function DashboardLayout() {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between" px="sm">
|
<Group justify="space-between" px="sm">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500}>{user?.username || 'User'}</Text>
|
<Text size="sm" fw={500}>
|
||||||
<Text size="xs" c="dimmed">{user?.role || 'user'}</Text>
|
{user?.username || "User"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{user?.role || "user"}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import type {
|
|||||||
User,
|
User,
|
||||||
Hook,
|
Hook,
|
||||||
WhatsAppAccount,
|
WhatsAppAccount,
|
||||||
|
WhatsAppAccountConfig,
|
||||||
|
PhoneNumberListResponse,
|
||||||
|
BusinessProfile,
|
||||||
|
BusinessProfileUpdateRequest,
|
||||||
|
TemplateListResponse,
|
||||||
|
TemplateUploadRequest,
|
||||||
|
FlowListResponse,
|
||||||
|
CatalogListResponse,
|
||||||
EventLog,
|
EventLog,
|
||||||
APIKey,
|
APIKey,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@@ -200,6 +208,155 @@ class ApiClient {
|
|||||||
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
|
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
|
// Event Logs API — uses RestHeadSpec native headers for server-side pagination/sorting
|
||||||
async getEventLogs(params?: {
|
async getEventLogs(params?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -21,8 +21,64 @@ import {
|
|||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
|
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
|
||||||
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
|
import { apiClient } from '../lib/api';
|
||||||
import type { WhatsAppAccount } from '../types';
|
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() {
|
export default function AccountsPage() {
|
||||||
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([]);
|
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([]);
|
||||||
@@ -46,8 +102,11 @@ export default function AccountsPage() {
|
|||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await listRecords<WhatsAppAccount>('whatsapp_accounts');
|
const [configuredAccounts, databaseAccounts] = await Promise.all([
|
||||||
setAccounts(data || []);
|
apiClient.getAccountConfigs(),
|
||||||
|
apiClient.getAccounts(),
|
||||||
|
]);
|
||||||
|
setAccounts(mergeAccounts(configuredAccounts || [], databaseAccounts || []));
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load accounts');
|
setError('Failed to load accounts');
|
||||||
@@ -73,7 +132,7 @@ export default function AccountsPage() {
|
|||||||
const handleEdit = (account: WhatsAppAccount) => {
|
const handleEdit = (account: WhatsAppAccount) => {
|
||||||
setEditingAccount(account);
|
setEditingAccount(account);
|
||||||
setFormData({
|
setFormData({
|
||||||
account_id: account.account_id || '',
|
account_id: account.account_id || account.id || '',
|
||||||
phone_number: account.phone_number,
|
phone_number: account.phone_number,
|
||||||
display_name: account.display_name || '',
|
display_name: account.display_name || '',
|
||||||
account_type: account.account_type,
|
account_type: account.account_type,
|
||||||
@@ -88,7 +147,7 @@ export default function AccountsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteRecord('whatsapp_accounts', id);
|
await apiClient.removeAccountConfig(id);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: 'Account deleted successfully',
|
message: 'Account deleted successfully',
|
||||||
@@ -108,11 +167,16 @@ export default function AccountsPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validate config JSON if not empty
|
const accountId = (editingAccount?.id || formData.account_id).trim();
|
||||||
if (formData.config) {
|
const parsedConfig = formData.config ? (() => {
|
||||||
try {
|
try {
|
||||||
JSON.parse(formData.config);
|
return JSON.parse(formData.config);
|
||||||
} catch {
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})() : null;
|
||||||
|
|
||||||
|
if (formData.config && parsedConfig === null) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: 'Config must be valid JSON',
|
message: 'Config must be valid JSON',
|
||||||
@@ -120,18 +184,29 @@ export default function AccountsPage() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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) {
|
if (editingAccount) {
|
||||||
await updateRecord('whatsapp_accounts', editingAccount.id, formData);
|
await apiClient.updateAccountConfig(payload);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: 'Account updated successfully',
|
message: 'Account updated successfully',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createRecord('whatsapp_accounts', formData);
|
await apiClient.addAccountConfig(payload);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: 'Account created successfully',
|
message: 'Account created successfully',
|
||||||
@@ -281,7 +356,12 @@ export default function AccountsPage() {
|
|||||||
value={formData.account_id}
|
value={formData.account_id}
|
||||||
onChange={(e) => setFormData({ ...formData, account_id: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, account_id: e.target.value })}
|
||||||
required
|
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
|
<TextInput
|
||||||
@@ -309,6 +389,7 @@ export default function AccountsPage() {
|
|||||||
{ value: 'business-api', label: 'Business API' }
|
{ value: 'business-api', label: 'Business API' }
|
||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
|
disabled={!!editingAccount}
|
||||||
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
|
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
223
web/src/pages/CatalogManagementPage.tsx
Normal file
223
web/src/pages/CatalogManagementPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,19 +77,35 @@ export default function DashboardPage() {
|
|||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [users, hooks, accounts, eventLogs] = await Promise.all([
|
const [usersResult, hooksResult, accountsResult, eventLogsResult] = await Promise.allSettled([
|
||||||
apiClient.getUsers(),
|
apiClient.getUsers(),
|
||||||
apiClient.getHooks(),
|
apiClient.getHooks(),
|
||||||
apiClient.getAccounts(),
|
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({
|
setStats({
|
||||||
users: users?.length || 0,
|
users: users?.length || 0,
|
||||||
hooks: hooks?.length || 0,
|
hooks: hooks?.length || 0,
|
||||||
accounts: accounts?.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) {
|
} catch (err) {
|
||||||
console.error('Failed to load stats:', err);
|
console.error('Failed to load stats:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pagination,
|
Modal,
|
||||||
Code,
|
Code,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
||||||
import { apiClient } from '../lib/api';
|
import { apiClient } from '../lib/api';
|
||||||
import type { EventLog } from '../types';
|
import type { EventLog } from '../types';
|
||||||
@@ -24,11 +25,17 @@ const ITEMS_PER_PAGE = 20;
|
|||||||
export default function EventLogsPage() {
|
export default function EventLogsPage() {
|
||||||
const [logs, setLogs] = useState<EventLog[]>([]);
|
const [logs, setLogs] = useState<EventLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
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
|
// Debounce search input by 400ms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,36 +43,95 @@ export default function EventLogsPage() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// Reset to page 1 on new search
|
const loadLogsPage = useCallback(async (targetOffset: number, reset: boolean) => {
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
const loadLogs = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
|
if (reset) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
} else {
|
||||||
|
setLoadingMore(true);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apiClient.getEventLogs({
|
const result = await apiClient.getEventLogs({
|
||||||
sort: '-created_at',
|
sort: '-created_at',
|
||||||
limit: ITEMS_PER_PAGE,
|
limit: ITEMS_PER_PAGE,
|
||||||
offset: (currentPage - 1) * ITEMS_PER_PAGE,
|
offset: targetOffset,
|
||||||
search: debouncedSearch || undefined,
|
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);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load event logs');
|
setError('Failed to load event logs');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (reset) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [currentPage, debouncedSearch]);
|
}
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs();
|
setLogs([]);
|
||||||
}, [loadLogs]);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -83,6 +149,7 @@ export default function EventLogsPage() {
|
|||||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<Text size="sm" c="dimmed">Try adjusting filters or refreshing the page.</Text>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,13 +230,24 @@ export default function EventLogsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{log.error ? (
|
{log.error ? (
|
||||||
<Tooltip label={log.error} position="left" multiline w={300}>
|
<Tooltip label="Click to view error details" position="left">
|
||||||
<Code color="red" style={{ cursor: 'help' }}>Error</Code>
|
<Code
|
||||||
|
component="button"
|
||||||
|
color="red"
|
||||||
|
onClick={() => openDetailsModal(`Event Error: ${log.event_type}`, log.error)}
|
||||||
|
style={{ cursor: 'pointer', border: 'none' }}
|
||||||
|
>
|
||||||
|
Error
|
||||||
|
</Code>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : log.data ? (
|
) : log.data ? (
|
||||||
<Tooltip label={log.data} position="left" multiline w={300}>
|
<Code
|
||||||
<Code style={{ cursor: 'help' }}>View Data</Code>
|
component="button"
|
||||||
</Tooltip>
|
onClick={() => openDetailsModal(`Event Data: ${log.event_type}`, log.data)}
|
||||||
|
style={{ cursor: 'pointer', border: 'none' }}
|
||||||
|
>
|
||||||
|
View Data
|
||||||
|
</Code>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">-</Text>
|
<Text size="sm" c="dimmed">-</Text>
|
||||||
)}
|
)}
|
||||||
@@ -181,20 +259,44 @@ export default function EventLogsPage() {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
<div ref={sentinelRef} />
|
||||||
<Group justify="center" mt="xl">
|
|
||||||
<Pagination total={totalPages} value={currentPage} onChange={setCurrentPage} />
|
{loadingMore && (
|
||||||
</Group>
|
<Center mt="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Showing {logs.length} of {totalCount} logs
|
{totalCount !== null
|
||||||
|
? `Showing ${logs.length} of ${totalCount} logs`
|
||||||
|
: `Showing ${logs.length} logs`}
|
||||||
</Text>
|
</Text>
|
||||||
{debouncedSearch && (
|
{debouncedSearch && (
|
||||||
<Text size="sm" c="dimmed">Filtered by: "{debouncedSearch}"</Text>
|
<Text size="sm" c="dimmed">Filtered by: "{debouncedSearch}"</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</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>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
225
web/src/pages/FlowManagementPage.tsx
Normal file
225
web/src/pages/FlowManagementPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconWebhook } from '@ta
|
|||||||
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
|
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
|
||||||
import type { Hook } from '../types';
|
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() {
|
export default function HooksPage() {
|
||||||
const [hooks, setHooks] = useState<Hook[]>([]);
|
const [hooks, setHooks] = useState<Hook[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -54,7 +60,7 @@ export default function HooksPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await listRecords<Hook>('hooks');
|
const data = await listRecords<Hook>('hooks');
|
||||||
setHooks(data || []);
|
setHooks(sortHooksAlphabetically(data || []));
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load hooks');
|
setError('Failed to load hooks');
|
||||||
|
|||||||
732
web/src/pages/SendMessagePage.tsx
Normal file
732
web/src/pages/SendMessagePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
web/src/pages/TemplateManagementPage.tsx
Normal file
372
web/src/pages/TemplateManagementPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
601
web/src/pages/WhatsAppBusinessPage.tsx
Normal file
601
web/src/pages/WhatsAppBusinessPage.tsx
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Code,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { IconAlertCircle, IconBuildingStore, IconPhoneCall } from "@tabler/icons-react";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import type {
|
||||||
|
BusinessProfile,
|
||||||
|
PhoneNumberListItem,
|
||||||
|
WhatsAppAccountConfig,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
function toPrettyJSON(value: unknown): string {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(error: unknown): string {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
if (typeof error.response?.data === "string") return error.response.data;
|
||||||
|
if (error.response?.data && typeof error.response.data === "object") {
|
||||||
|
return toPrettyJSON(error.response.data);
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return "Request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorPayload(error: unknown): unknown {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
return error.response?.data ?? { message: error.message };
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return { message: error.message };
|
||||||
|
return { message: "Request failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function websitesToText(websites?: string[]): string {
|
||||||
|
return (websites || []).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWebsitesInput(input: string): string[] {
|
||||||
|
return input
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultPanelProps = {
|
||||||
|
title: string;
|
||||||
|
payload: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResultPanel({ title, payload }: ResultPanelProps) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Text fw={600} size="sm" mb="xs">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Code block>{toPrettyJSON(payload)}</Code>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WhatsAppBusinessPage() {
|
||||||
|
const [accounts, setAccounts] = useState<WhatsAppAccountConfig[]>([]);
|
||||||
|
const [loadingAccounts, setLoadingAccounts] = useState(true);
|
||||||
|
const [accountId, setAccountId] = useState("");
|
||||||
|
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberListItem[]>([]);
|
||||||
|
|
||||||
|
const [phoneNumberIdForCode, setPhoneNumberIdForCode] = useState("");
|
||||||
|
const [codeMethod, setCodeMethod] = useState<"SMS" | "VOICE">("SMS");
|
||||||
|
const [language, setLanguage] = useState("en_US");
|
||||||
|
|
||||||
|
const [phoneNumberIdForVerify, setPhoneNumberIdForVerify] = useState("");
|
||||||
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
|
|
||||||
|
const [phoneNumberIdForRegister, setPhoneNumberIdForRegister] = useState("");
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
|
||||||
|
const [businessProfile, setBusinessProfile] = useState<BusinessProfile | null>(null);
|
||||||
|
const [about, setAbout] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [websites, setWebsites] = useState("");
|
||||||
|
const [vertical, setVertical] = useState("");
|
||||||
|
|
||||||
|
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [responseHistory, setResponseHistory] = useState<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: "success" | "error";
|
||||||
|
payload: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const businessAccounts = useMemo(
|
||||||
|
() =>
|
||||||
|
accounts
|
||||||
|
.filter((entry) => entry.type === "business-api" && !entry.disabled)
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id, undefined, { sensitivity: "base" })),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountOptions = businessAccounts.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.id} (business-api)`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const phoneNumberOptions = phoneNumbers.map((entry) => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.display_phone_number || entry.phone_number} (${entry.id})`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAccounts = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingAccounts(true);
|
||||||
|
const result = await apiClient.getAccountConfigs();
|
||||||
|
setAccounts(result || []);
|
||||||
|
} catch (error) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to load WhatsApp accounts",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingAccounts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accountId && businessAccounts.length > 0) {
|
||||||
|
setAccountId(businessAccounts[0].id);
|
||||||
|
}
|
||||||
|
}, [businessAccounts, accountId]);
|
||||||
|
|
||||||
|
const appendResponse = (entry: {
|
||||||
|
title: string;
|
||||||
|
status: "success" | "error";
|
||||||
|
payload: unknown;
|
||||||
|
}) => {
|
||||||
|
const item = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
setResponseHistory((prev) => [item, ...prev].slice(0, 20));
|
||||||
|
if (entry.status === "success") {
|
||||||
|
console.info(`[WhatsApp Business] ${entry.title} success`, entry.payload);
|
||||||
|
} else {
|
||||||
|
console.error(`[WhatsApp Business] ${entry.title} error`, entry.payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async <T,>(key: string, actionTitle: string, action: () => Promise<T>) => {
|
||||||
|
if (!accountId) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Validation Error",
|
||||||
|
message: "Select a business account first",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading((prev) => ({ ...prev, [key]: true }));
|
||||||
|
const result = await action();
|
||||||
|
appendResponse({
|
||||||
|
title: actionTitle,
|
||||||
|
status: "success",
|
||||||
|
payload: result,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
appendResponse({
|
||||||
|
title: actionTitle,
|
||||||
|
status: "error",
|
||||||
|
payload: extractErrorPayload(error),
|
||||||
|
});
|
||||||
|
notifications.show({
|
||||||
|
title: "Request Failed",
|
||||||
|
message: formatError(error),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.error(error);
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
setActionLoading((prev) => ({ ...prev, [key]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListPhoneNumbers = async () => {
|
||||||
|
const result = await runAction("listPhoneNumbers", "List Phone Numbers", async () => {
|
||||||
|
return apiClient.listPhoneNumbers(accountId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setPhoneNumbers(result.data || []);
|
||||||
|
notifications.show({
|
||||||
|
title: "Success",
|
||||||
|
message: `Loaded ${result.data?.length || 0} phone number(s)`,
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestVerificationCode = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const response = await runAction("requestCode", "Request Verification Code", async () => {
|
||||||
|
return apiClient.requestVerificationCode({
|
||||||
|
account_id: accountId,
|
||||||
|
phone_number_id: phoneNumberIdForCode.trim(),
|
||||||
|
code_method: codeMethod,
|
||||||
|
language: language.trim() || "en_US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Success",
|
||||||
|
message: "Verification code requested",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyCode = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const response = await runAction("verifyCode", "Verify Code", async () => {
|
||||||
|
return apiClient.verifyPhoneCode({
|
||||||
|
account_id: accountId,
|
||||||
|
phone_number_id: phoneNumberIdForVerify.trim(),
|
||||||
|
code: verificationCode.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Success",
|
||||||
|
message: "Verification code accepted",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterPhoneNumber = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const response = await runAction("registerPhoneNumber", "Register Phone Number", async () => {
|
||||||
|
return apiClient.registerPhoneNumber({
|
||||||
|
account_id: accountId,
|
||||||
|
phone_number_id: phoneNumberIdForRegister.trim(),
|
||||||
|
pin: pin.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Success",
|
||||||
|
message: "Phone number registration submitted",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGetBusinessProfile = async () => {
|
||||||
|
const profile = await runAction("getBusinessProfile", "Get Business Profile", async () => {
|
||||||
|
return apiClient.getBusinessProfile(accountId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
setBusinessProfile(profile);
|
||||||
|
setAbout(profile.about || "");
|
||||||
|
setAddress(profile.address || "");
|
||||||
|
setDescription(profile.description || "");
|
||||||
|
setEmail(profile.email || "");
|
||||||
|
setWebsites(websitesToText(profile.websites));
|
||||||
|
setVertical(profile.vertical || "");
|
||||||
|
notifications.show({
|
||||||
|
title: "Success",
|
||||||
|
message: "Business profile loaded",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateBusinessProfile = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
account_id: accountId,
|
||||||
|
about: about.trim(),
|
||||||
|
address: address.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
websites: parseWebsitesInput(websites),
|
||||||
|
vertical: vertical.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await runAction("updateBusinessProfile", "Update Business Profile", async () => {
|
||||||
|
const result = await apiClient.updateBusinessProfile(payload);
|
||||||
|
return { payload, response: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
notifications.show({
|
||||||
|
title: "Success",
|
||||||
|
message: "Business profile updated",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>WhatsApp Business Management</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Select a business account, then manage phone number verification/registration and business profile details.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600}>Business Account</Text>
|
||||||
|
<Badge variant="light" color="blue">
|
||||||
|
{businessAccounts.length} business account(s)
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Select
|
||||||
|
label="Account"
|
||||||
|
data={accountOptions}
|
||||||
|
value={accountId}
|
||||||
|
onChange={(value) => setAccountId(value || "")}
|
||||||
|
searchable
|
||||||
|
disabled={loadingAccounts}
|
||||||
|
placeholder="Select an account"
|
||||||
|
/>
|
||||||
|
{businessAccounts.length === 0 && !loadingAccounts && (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" title="No Business API Accounts">
|
||||||
|
No enabled `business-api` accounts were found. Add one in the WhatsApp Accounts page first.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<IconPhoneCall size={20} />
|
||||||
|
<Text fw={600}>Phone Number Management</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
onClick={handleListPhoneNumbers}
|
||||||
|
loading={!!actionLoading.listPhoneNumbers}
|
||||||
|
disabled={!accountId}
|
||||||
|
>
|
||||||
|
List Phone Numbers
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{phoneNumbers.length > 0 && (
|
||||||
|
<Table withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Display Number</Table.Th>
|
||||||
|
<Table.Th>Verified Name</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Quality</Table.Th>
|
||||||
|
<Table.Th>Throughput</Table.Th>
|
||||||
|
<Table.Th>Phone Number ID</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{phoneNumbers.map((entry) => (
|
||||||
|
<Table.Tr key={entry.id}>
|
||||||
|
<Table.Td>{entry.display_phone_number || "-"}</Table.Td>
|
||||||
|
<Table.Td>{entry.verified_name || "-"}</Table.Td>
|
||||||
|
<Table.Td>{entry.code_verification_status || "-"}</Table.Td>
|
||||||
|
<Table.Td>{entry.quality_rating || "-"}</Table.Td>
|
||||||
|
<Table.Td>{entry.throughput?.level || "-"}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Code>{entry.id}</Code>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 3 }}>
|
||||||
|
<Paper withBorder p="sm">
|
||||||
|
<form onSubmit={handleRequestVerificationCode}>
|
||||||
|
<Stack>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Request Verification Code
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
label="Phone Number ID"
|
||||||
|
data={phoneNumberOptions}
|
||||||
|
value={phoneNumberIdForCode}
|
||||||
|
onChange={(value) => setPhoneNumberIdForCode(value || "")}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Or enter ID manually"
|
||||||
|
value={phoneNumberIdForCode}
|
||||||
|
onChange={(e) => setPhoneNumberIdForCode(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Code Method"
|
||||||
|
value={codeMethod}
|
||||||
|
onChange={(value) => setCodeMethod((value as "SMS" | "VOICE") || "SMS")}
|
||||||
|
data={[
|
||||||
|
{ value: "SMS", label: "SMS" },
|
||||||
|
{ value: "VOICE", label: "VOICE" },
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Language"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.currentTarget.value)}
|
||||||
|
placeholder="en_US"
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={!!actionLoading.requestCode} disabled={!accountId}>
|
||||||
|
Request Code
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="sm">
|
||||||
|
<form onSubmit={handleVerifyCode}>
|
||||||
|
<Stack>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Verify Code
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
label="Phone Number ID"
|
||||||
|
data={phoneNumberOptions}
|
||||||
|
value={phoneNumberIdForVerify}
|
||||||
|
onChange={(value) => setPhoneNumberIdForVerify(value || "")}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Or enter ID manually"
|
||||||
|
value={phoneNumberIdForVerify}
|
||||||
|
onChange={(e) => setPhoneNumberIdForVerify(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Verification Code"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={!!actionLoading.verifyCode} disabled={!accountId}>
|
||||||
|
Verify Code
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="sm">
|
||||||
|
<form onSubmit={handleRegisterPhoneNumber}>
|
||||||
|
<Stack>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Register Phone Number
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
label="Phone Number ID"
|
||||||
|
data={phoneNumberOptions}
|
||||||
|
value={phoneNumberIdForRegister}
|
||||||
|
onChange={(value) => setPhoneNumberIdForRegister(value || "")}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Or enter ID manually"
|
||||||
|
value={phoneNumberIdForRegister}
|
||||||
|
onChange={(e) => setPhoneNumberIdForRegister(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="PIN (4-8 digits)"
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => setPin(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={!!actionLoading.registerPhoneNumber} disabled={!accountId}>
|
||||||
|
Register Number
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<IconBuildingStore size={20} />
|
||||||
|
<Text fw={600}>Business Profile</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={handleGetBusinessProfile}
|
||||||
|
loading={!!actionLoading.getBusinessProfile}
|
||||||
|
disabled={!accountId}
|
||||||
|
>
|
||||||
|
Get Business Profile
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{businessProfile?.profile_picture_url && (
|
||||||
|
<Alert color="blue" title="Profile Picture URL">
|
||||||
|
{businessProfile.profile_picture_url}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdateBusinessProfile}>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="About" value={about} onChange={(e) => setAbout(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Address" value={address} onChange={(e) => setAddress(e.currentTarget.value)} />
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
|
||||||
|
<Textarea
|
||||||
|
label="Websites"
|
||||||
|
description="One URL per line or comma-separated"
|
||||||
|
value={websites}
|
||||||
|
onChange={(e) => setWebsites(e.currentTarget.value)}
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<TextInput label="Vertical" value={vertical} onChange={(e) => setVertical(e.currentTarget.value)} />
|
||||||
|
<Button type="submit" loading={!!actionLoading.updateBusinessProfile} disabled={!accountId}>
|
||||||
|
Update Business Profile
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{responseHistory.length > 0 && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600}>Response History</Text>
|
||||||
|
<Button variant="subtle" color="gray" size="xs" onClick={() => setResponseHistory([])}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{responseHistory.map((entry) => (
|
||||||
|
<ResultPanel
|
||||||
|
key={entry.id}
|
||||||
|
title={`${entry.title} - ${entry.status.toUpperCase()} - ${new Date(entry.createdAt).toLocaleString()}`}
|
||||||
|
payload={entry.payload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,163 @@ export interface WhatsAppAccount {
|
|||||||
updated_at: string;
|
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 {
|
export interface EventLog {
|
||||||
id: string;
|
id: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user