Skip to content

Commit 0618684

Browse files
authored
Merge pull request #757 from microboxlabs/based/754-whatsapp-settings-ui
feat(app): WhatsApp channel settings card (configure + test)
2 parents 834d63d + f51d27d commit 0618684

11 files changed

Lines changed: 751 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { forwardToQuarkus } from "@/app/api/utils/quarkus-proxy";
2+
3+
/**
4+
* POST /api/admin/orgs/[orgId]/integrations/connections/[connId]/test
5+
*
6+
* Runs the provider's live connectivity probe. Proxies to Quarkus
7+
* `POST /api/v1/orgs/{orgId}/integrations/connections/{connId}/test`.
8+
*/
9+
export async function POST(
10+
request: Request,
11+
{ params }: { params: Promise<{ orgId: string; connId: string }> },
12+
) {
13+
const { orgId, connId } = await params;
14+
let body: unknown = {};
15+
try {
16+
body = await request.json();
17+
} catch {
18+
// No body is fine — the test request is optional.
19+
}
20+
return forwardToQuarkus(
21+
`/api/v1/orgs/${encodeURIComponent(orgId)}/integrations/connections/${encodeURIComponent(connId)}/test`,
22+
{ method: "POST", body },
23+
);
24+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NextResponse } from "next/server";
2+
import { forwardToQuarkus } from "@/app/api/utils/quarkus-proxy";
3+
4+
/**
5+
* GET /api/admin/orgs/[orgId]/integrations/connections — list connections
6+
* POST /api/admin/orgs/[orgId]/integrations/connections — create a connection
7+
*
8+
* Proxies to Quarkus `GET/POST /api/v1/orgs/{orgId}/integrations/connections`
9+
* (miot-integrations). Auth + tenant scoping are delegated to Quarkus.
10+
*/
11+
export async function GET(
12+
_request: Request,
13+
{ params }: { params: Promise<{ orgId: string }> },
14+
) {
15+
const { orgId } = await params;
16+
const safe = encodeURIComponent(orgId);
17+
return forwardToQuarkus(`/api/v1/orgs/${safe}/integrations/connections`);
18+
}
19+
20+
export async function POST(
21+
request: Request,
22+
{ params }: { params: Promise<{ orgId: string }> },
23+
) {
24+
const { orgId } = await params;
25+
const safe = encodeURIComponent(orgId);
26+
let body: unknown;
27+
try {
28+
body = await request.json();
29+
} catch {
30+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
31+
}
32+
return forwardToQuarkus(`/api/v1/orgs/${safe}/integrations/connections`, {
33+
method: "POST",
34+
body,
35+
});
36+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { NextResponse } from "next/server";
2+
import { forwardToQuarkus } from "@/app/api/utils/quarkus-proxy";
3+
4+
/**
5+
* POST /api/admin/orgs/[orgId]/integrations/credential-profiles — create a
6+
* credential profile (e.g. a BEARER_TOKEN holding the WhatsApp access token).
7+
*
8+
* Proxies to Quarkus `POST /api/v1/orgs/{orgId}/integrations/credential-profiles`.
9+
*/
10+
export async function POST(
11+
request: Request,
12+
{ params }: { params: Promise<{ orgId: string }> },
13+
) {
14+
const { orgId } = await params;
15+
const safe = encodeURIComponent(orgId);
16+
let body: unknown;
17+
try {
18+
body = await request.json();
19+
} catch {
20+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
21+
}
22+
return forwardToQuarkus(
23+
`/api/v1/orgs/${safe}/integrations/credential-profiles`,
24+
{ method: "POST", body },
25+
);
26+
}

turbo-repo/apps/app/src/features/settings-admin/components/org-detail-panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useOrgMembers } from "../hooks/use-org-members";
66
import { useOrgModules } from "../hooks/use-org-modules";
77
import MembersList from "./members-list";
88
import ModulesList from "./modules-list";
9+
import WhatsAppChannelCard from "../whatsapp/whatsapp-channel-card";
910

1011
interface OrgDetailPanelProps {
1112
readonly orgSlug: string | null;
@@ -45,6 +46,7 @@ export default function OrgDetailPanel({ orgSlug, dict }: OrgDetailPanelProps) {
4546
error={modulesError}
4647
dict={dict}
4748
/>
49+
<WhatsAppChannelCard orgSlug={orgSlug} dict={dict} />
4850
<MembersList
4951
members={members}
5052
isLoading={membersLoading}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import useSWR from "swr";
5+
import {
6+
createWhatsAppConnection,
7+
fetchWhatsAppConnection,
8+
testWhatsAppConnection,
9+
} from "./whatsapp-data-service";
10+
import type {
11+
ConnectionTestResult,
12+
IntegrationConnection,
13+
WhatsAppFormData,
14+
} from "./whatsapp.types";
15+
16+
/**
17+
* The org's WhatsApp connection + create/test actions, via the Next.js proxy
18+
* to Quarkus. Returns a null SWR key when orgSlug is empty so the fetch skips.
19+
*/
20+
export function useOrgWhatsApp(orgSlug: string | null) {
21+
const [actionLoading, setActionLoading] = useState(false);
22+
23+
const { data, error, isLoading, mutate } = useSWR<
24+
IntegrationConnection | null,
25+
Error
26+
>(
27+
orgSlug ? ["org-whatsapp", orgSlug] : null,
28+
([, slug]: [string, string]) => fetchWhatsAppConnection(slug),
29+
{ revalidateOnFocus: false, dedupingInterval: 60_000 },
30+
);
31+
32+
function requireSlug(): string {
33+
if (!orgSlug) {
34+
throw new Error("No organization selected");
35+
}
36+
return orgSlug;
37+
}
38+
39+
async function create(
40+
form: WhatsAppFormData,
41+
): Promise<IntegrationConnection> {
42+
const slug = requireSlug();
43+
setActionLoading(true);
44+
try {
45+
const created = await createWhatsAppConnection(slug, form);
46+
await mutate();
47+
return created;
48+
} finally {
49+
setActionLoading(false);
50+
}
51+
}
52+
53+
async function test(connectionId: string): Promise<ConnectionTestResult> {
54+
const slug = requireSlug();
55+
setActionLoading(true);
56+
try {
57+
const result = await testWhatsAppConnection(slug, connectionId);
58+
await mutate();
59+
return result;
60+
} finally {
61+
setActionLoading(false);
62+
}
63+
}
64+
65+
return {
66+
connection: data ?? null,
67+
isLoading,
68+
error: error ?? null,
69+
actionLoading,
70+
create,
71+
test,
72+
refresh: mutate,
73+
};
74+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"use client";
2+
3+
import { Badge, Button, Spinner } from "flowbite-react";
4+
import { useState, type ReactNode } from "react";
5+
import { FaWhatsapp } from "react-icons/fa";
6+
import { toast } from "sonner";
7+
import type { I18nRecord } from "@/features/i18n/i18n.service.types";
8+
import { tr } from "@/features/i18n/tr.service";
9+
import { useOrgWhatsApp } from "./use-org-whatsapp";
10+
import { WhatsAppConnectionModal } from "./whatsapp-connection-modal";
11+
import type { IntegrationConnection, WhatsAppFormData } from "./whatsapp.types";
12+
13+
interface WhatsAppChannelCardProps {
14+
readonly orgSlug: string | null;
15+
readonly dict: I18nRecord;
16+
}
17+
18+
/**
19+
* Settings card for the org's WhatsApp channel. When no connection exists it
20+
* offers a Configure action (create); once configured it shows status + a live
21+
* Test. Edit/delete are deferred (the backend has no update/delete yet).
22+
*/
23+
export default function WhatsAppChannelCard({
24+
orgSlug,
25+
dict,
26+
}: WhatsAppChannelCardProps) {
27+
const waDict = (dict?.whatsappChannel as I18nRecord) ?? {};
28+
const { connection, isLoading, error, actionLoading, create, test } =
29+
useOrgWhatsApp(orgSlug);
30+
const [showModal, setShowModal] = useState(false);
31+
const [submitError, setSubmitError] = useState<Error | null>(null);
32+
33+
async function handleCreate(form: WhatsAppFormData) {
34+
setSubmitError(null);
35+
try {
36+
await create(form);
37+
setShowModal(false);
38+
toast.success(tr("toast.created", waDict));
39+
} catch (err) {
40+
setSubmitError(
41+
err instanceof Error ? err : new Error(tr("toast.error", waDict)),
42+
);
43+
}
44+
}
45+
46+
async function handleTest() {
47+
if (!connection) return;
48+
try {
49+
const result = await test(connection.id);
50+
if (result.success) {
51+
toast.success(result.message || tr("toast.testSuccess", waDict));
52+
} else {
53+
toast.error(result.message || tr("toast.testFailed", waDict));
54+
}
55+
} catch (err) {
56+
toast.error(
57+
err instanceof Error ? err.message : tr("toast.error", waDict),
58+
);
59+
}
60+
}
61+
62+
function renderBody(): ReactNode {
63+
if (isLoading) {
64+
return <Spinner size="sm" />;
65+
}
66+
if (error) {
67+
return (
68+
<p className="text-sm text-red-600 dark:text-red-400">
69+
{tr("loadError", waDict)}
70+
</p>
71+
);
72+
}
73+
if (connection) {
74+
return (
75+
<ConfiguredRow
76+
connection={connection}
77+
dict={waDict}
78+
testing={actionLoading}
79+
onTest={handleTest}
80+
/>
81+
);
82+
}
83+
return (
84+
<Button
85+
size="xs"
86+
color="green"
87+
onClick={() => setShowModal(true)}
88+
disabled={!orgSlug}
89+
>
90+
{tr("configureButton", waDict)}
91+
</Button>
92+
);
93+
}
94+
95+
return (
96+
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
97+
<div className="flex items-start justify-between">
98+
<div className="flex items-center gap-2">
99+
<FaWhatsapp className="h-5 w-5 text-green-500" />
100+
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
101+
{tr("title", waDict)}
102+
</h3>
103+
</div>
104+
{connection && <StatusBadge status={connection.status} dict={waDict} />}
105+
</div>
106+
107+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
108+
{tr("description", waDict)}
109+
</p>
110+
111+
<div className="mt-3">{renderBody()}</div>
112+
113+
<WhatsAppConnectionModal
114+
show={showModal}
115+
onClose={() => setShowModal(false)}
116+
onSubmit={handleCreate}
117+
loading={actionLoading}
118+
error={submitError}
119+
dict={waDict}
120+
/>
121+
</div>
122+
);
123+
}
124+
125+
interface ConfiguredRowProps {
126+
readonly connection: IntegrationConnection;
127+
readonly dict: I18nRecord;
128+
readonly testing: boolean;
129+
readonly onTest: () => void;
130+
}
131+
132+
function ConfiguredRow({ connection, dict, testing, onTest }: ConfiguredRowProps) {
133+
const rawPhone = connection.metadata?.phone_number_id;
134+
const phone = typeof rawPhone === "string" ? rawPhone : "—";
135+
return (
136+
<div className="flex items-center justify-between gap-2">
137+
<div className="text-sm text-gray-700 dark:text-gray-300">
138+
<span className="font-medium">{tr("phoneLabel", dict)}:</span> {phone}
139+
</div>
140+
<Button size="xs" color="light" disabled={testing} onClick={onTest}>
141+
{testing ? <Spinner size="sm" /> : tr("testButton", dict)}
142+
</Button>
143+
</div>
144+
);
145+
}
146+
147+
interface StatusBadgeProps {
148+
readonly status: IntegrationConnection["status"];
149+
readonly dict: I18nRecord;
150+
}
151+
152+
function StatusBadge({ status, dict }: StatusBadgeProps) {
153+
const map: Record<
154+
IntegrationConnection["status"],
155+
{ color: "success" | "gray" | "failure"; key: string }
156+
> = {
157+
ACTIVE: { color: "success", key: "status.active" },
158+
DRAFT: { color: "gray", key: "status.draft" },
159+
INACTIVE: { color: "gray", key: "status.inactive" },
160+
TEST_FAILED: { color: "failure", key: "status.failed" },
161+
};
162+
const entry = map[status] ?? map.DRAFT;
163+
return <Badge color={entry.color}>{tr(entry.key, dict)}</Badge>;
164+
}

0 commit comments

Comments
 (0)