mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Replace the flat 8-tab Settings page with a cleaner information architecture: a grouped left rail (Account / Workspace) plus a new Overview landing with live status tiles. Related concerns are merged — "Login & security" (password + sessions) and "Fields & tags" (tags + custom fields). - New shell: settings-sections (section model + legacy ?tab= mapping, tags/custom-fields -> fields), settings-rail, settings-overview (live counts + identity), security-panel, fields-and-tags-panel, settings-panel-head, settings-chip, role-meta (shared role chip metadata, de-duped from members-tab). - Restyle the light panels to the mockup; reframe the heavy panels (WhatsApp, Templates) with the shared panel header, internals intact. - Tokens: add --card-2 (light+dark) and @theme mappings for primary-hover/soft/soft-2 so the redesign stays fully tokenized. - ?tab= remains the URL source of truth, keeping existing sidebar/header deep-links working. Overview decouples the slow WhatsApp Meta health-ping from the cheap count queries so a flaky ping never blanks the whole landing; template counts use head-only count queries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
847 lines
33 KiB
TypeScript
847 lines
33 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Eye,
|
|
EyeOff,
|
|
Copy,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
ExternalLink,
|
|
Zap,
|
|
AlertTriangle,
|
|
RotateCcw,
|
|
} from 'lucide-react';
|
|
import { createClient } from '@/lib/supabase/client';
|
|
import { useAuth } from '@/hooks/use-auth';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { SettingsPanelHead } from './settings-panel-head';
|
|
import {
|
|
Accordion,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
AccordionContent,
|
|
} from '@/components/ui/accordion';
|
|
import type { WhatsAppConfig as WhatsAppConfigType } from '@/types';
|
|
|
|
const MASKED_TOKEN = '••••••••••••••••';
|
|
|
|
type ConnectionStatus = 'connected' | 'disconnected' | 'unknown';
|
|
type ResetReason = 'token_corrupted' | 'meta_api_error' | null;
|
|
|
|
export function WhatsAppConfig() {
|
|
const supabase = createClient();
|
|
// After multi-user, whatsapp_config is one-row-per-account, not
|
|
// one-row-per-user. We pull `accountId` straight off the auth
|
|
// context and key every read off it — so a teammate who just
|
|
// joined an account sees the inviter's saved config without
|
|
// having to re-enter anything.
|
|
const { user, accountId, loading: authLoading, profileLoading } = useAuth();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
const [resetting, setResetting] = useState(false);
|
|
const [showToken, setShowToken] = useState(false);
|
|
const [config, setConfig] = useState<WhatsAppConfigType | null>(null);
|
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('unknown');
|
|
const [resetReason, setResetReason] = useState<ResetReason>(null);
|
|
const [statusMessage, setStatusMessage] = useState<string>('');
|
|
|
|
const [phoneNumberId, setPhoneNumberId] = useState('');
|
|
const [wabaId, setWabaId] = useState('');
|
|
const [accessToken, setAccessToken] = useState('');
|
|
const [verifyToken, setVerifyToken] = useState('');
|
|
const [pin, setPin] = useState('');
|
|
const [tokenEdited, setTokenEdited] = useState(false);
|
|
|
|
// True once /register has succeeded on Meta's side (timestamp set
|
|
// in the row). When false, the saved config is metadata-only and
|
|
// Meta will silently drop every inbound event — that's the
|
|
// multi-number bug that prompted this work.
|
|
const isRegistered = Boolean(config?.registered_at);
|
|
const lastRegistrationError = config?.last_registration_error ?? null;
|
|
|
|
const [verifyingRegistration, setVerifyingRegistration] = useState(false);
|
|
type RegistrationProbe = {
|
|
live: boolean;
|
|
checks: Record<string, boolean | null>;
|
|
errors?: string[];
|
|
last_registration_error?: string | null;
|
|
registered_at?: string | null;
|
|
subscribed_apps_at?: string | null;
|
|
};
|
|
const [registrationProbe, setRegistrationProbe] =
|
|
useState<RegistrationProbe | null>(null);
|
|
|
|
const webhookUrl =
|
|
typeof window !== 'undefined'
|
|
? `${window.location.origin}/api/whatsapp/webhook`
|
|
: '';
|
|
|
|
const fetchConfig = useCallback(async (acctId: string) => {
|
|
setLoading(true);
|
|
try {
|
|
// Load form values from Supabase (shows what's in DB).
|
|
// Switched from `user_id` (which would only match the row's
|
|
// original author) to `account_id` so every member of the
|
|
// account sees the same saved configuration. UNIQUE(account_id)
|
|
// on the table guarantees the .maybeSingle() return type
|
|
// remains accurate.
|
|
const { data, error } = await supabase
|
|
.from('whatsapp_config')
|
|
.select('*')
|
|
.eq('account_id', acctId)
|
|
.maybeSingle();
|
|
|
|
if (error) {
|
|
console.error('Failed to load config row:', error);
|
|
}
|
|
|
|
if (data) {
|
|
setConfig(data);
|
|
setPhoneNumberId(data.phone_number_id || '');
|
|
setWabaId(data.waba_id || '');
|
|
setAccessToken(MASKED_TOKEN);
|
|
setVerifyToken('');
|
|
setPin('');
|
|
setTokenEdited(false);
|
|
} else {
|
|
setConfig(null);
|
|
setPhoneNumberId('');
|
|
setWabaId('');
|
|
setAccessToken('');
|
|
setVerifyToken('');
|
|
setPin('');
|
|
setTokenEdited(false);
|
|
}
|
|
// Clear any stale probe result when reloading the row.
|
|
setRegistrationProbe(null);
|
|
|
|
// Then verify health via the API (decrypts token + pings Meta)
|
|
if (data) {
|
|
try {
|
|
const res = await fetch('/api/whatsapp/config', { method: 'GET' });
|
|
const payload = await res.json();
|
|
|
|
if (payload.connected) {
|
|
setConnectionStatus('connected');
|
|
setResetReason(null);
|
|
setStatusMessage('');
|
|
} else {
|
|
setConnectionStatus('disconnected');
|
|
setResetReason(payload.needs_reset ? 'token_corrupted' : payload.reason === 'meta_api_error' ? 'meta_api_error' : null);
|
|
setStatusMessage(payload.message || '');
|
|
}
|
|
} catch (err) {
|
|
console.error('Health check failed:', err);
|
|
setConnectionStatus('disconnected');
|
|
}
|
|
} else {
|
|
setConnectionStatus('disconnected');
|
|
setResetReason(null);
|
|
setStatusMessage('');
|
|
}
|
|
} catch (err) {
|
|
console.error('fetchConfig error:', err);
|
|
toast.error('Failed to load WhatsApp configuration');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [supabase]);
|
|
|
|
useEffect(() => {
|
|
// Need both the auth session (`!authLoading`) AND the profile
|
|
// (`!profileLoading`, which carries `accountId`). Without the
|
|
// second guard, the effect would fire with `accountId === null`
|
|
// for the first render window and bail without ever retrying
|
|
// once the profile arrives.
|
|
if (authLoading || profileLoading) return;
|
|
if (!user || !accountId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
fetchConfig(accountId);
|
|
}, [authLoading, profileLoading, user, accountId, fetchConfig]);
|
|
|
|
async function handleSave() {
|
|
if (!phoneNumberId.trim()) {
|
|
toast.error('Phone Number ID is required');
|
|
return;
|
|
}
|
|
if (!config && (!accessToken.trim() || !tokenEdited)) {
|
|
toast.error('Access Token is required for initial setup');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaving(true);
|
|
|
|
// Always POST through the API — it verifies with Meta and encrypts
|
|
// the access_token server-side with ENCRYPTION_KEY. Skipping this
|
|
// and writing direct to Supabase stores the token in plaintext,
|
|
// which then fails decryption on every subsequent health check.
|
|
const payload: Record<string, unknown> = {
|
|
phone_number_id: phoneNumberId.trim(),
|
|
waba_id: wabaId.trim() || null,
|
|
verify_token: verifyToken.trim() || null,
|
|
// Optional — only sent when the user filled it in. The server
|
|
// requires it on first save or when changing numbers; for a
|
|
// simple token rotation, leaving it blank skips re-register.
|
|
pin: pin.trim() || null,
|
|
};
|
|
|
|
if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) {
|
|
payload.access_token = accessToken.trim();
|
|
} else if (config) {
|
|
// Existing config — reuse stored encrypted token by decrypting on the
|
|
// server. But our POST handler requires an access_token to verify
|
|
// with Meta. If the user didn't change the token, we need to signal
|
|
// that. Simplest: require token re-entry if they're updating.
|
|
toast.error('Please re-enter the Access Token to save changes');
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
|
|
const res = await fetch('/api/whatsapp/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
toast.error(data.error || 'Failed to save configuration');
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
|
|
// The route now returns a structured outcome:
|
|
// * registered=true → number is live, events will flow
|
|
// * registered=false → credentials saved but /register
|
|
// failed; UI shows the specific error
|
|
// and a retry path. registration_error
|
|
// is human-readable from Meta.
|
|
if (data.registered === false && data.registration_error) {
|
|
toast.error(
|
|
`Saved, but Meta couldn't register the number: ${data.registration_error}`,
|
|
{ duration: 12000 },
|
|
);
|
|
} else if (data.registration_skipped) {
|
|
// Credentials saved + verified, but /register was skipped
|
|
// because no PIN was supplied (e.g. a Meta test number).
|
|
// Don't claim the number is "Live" — point at the
|
|
// Registration status banner instead.
|
|
toast.success(
|
|
'Credentials saved and verified. Inbound registration was skipped (no PIN) — see Registration status below.',
|
|
{ duration: 10000 },
|
|
);
|
|
setPin('');
|
|
} else {
|
|
toast.success(
|
|
data.phone_info?.verified_name
|
|
? `Live — ${data.phone_info.verified_name} can now receive events.`
|
|
: 'WhatsApp connected. Events will start flowing within a minute.',
|
|
);
|
|
// Clear the PIN so subsequent saves don't accidentally
|
|
// re-register (which would void the active subscription if
|
|
// the PIN became stale).
|
|
setPin('');
|
|
}
|
|
|
|
if (accountId) await fetchConfig(accountId);
|
|
} catch (err) {
|
|
console.error('Save error:', err);
|
|
toast.error('Failed to save configuration');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleTestConnection() {
|
|
try {
|
|
setTesting(true);
|
|
const res = await fetch('/api/whatsapp/config', { method: 'GET' });
|
|
const payload = await res.json();
|
|
|
|
if (payload.connected) {
|
|
setConnectionStatus('connected');
|
|
setResetReason(null);
|
|
setStatusMessage('');
|
|
toast.success(
|
|
payload.phone_info?.verified_name
|
|
? `Connected to ${payload.phone_info.verified_name}`
|
|
: 'API connection successful'
|
|
);
|
|
} else {
|
|
setConnectionStatus('disconnected');
|
|
setResetReason(payload.needs_reset ? 'token_corrupted' : payload.reason === 'meta_api_error' ? 'meta_api_error' : null);
|
|
setStatusMessage(payload.message || '');
|
|
toast.error(payload.message || 'API connection failed');
|
|
}
|
|
} catch (err) {
|
|
console.error('Test connection error:', err);
|
|
setConnectionStatus('disconnected');
|
|
toast.error('Connection test failed. Check network and try again.');
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
async function handleVerifyRegistration() {
|
|
setVerifyingRegistration(true);
|
|
setRegistrationProbe(null);
|
|
try {
|
|
const res = await fetch('/api/whatsapp/config/verify-registration', {
|
|
method: 'GET',
|
|
});
|
|
const data = (await res.json()) as RegistrationProbe;
|
|
setRegistrationProbe(data);
|
|
if (data.live) {
|
|
toast.success('Number is fully wired — Meta is delivering events.');
|
|
} else {
|
|
toast.error(
|
|
'Number is not fully registered. See the checks below for which step failed.',
|
|
{ duration: 8000 },
|
|
);
|
|
}
|
|
if (accountId) await fetchConfig(accountId);
|
|
} catch (err) {
|
|
console.error('verify-registration failed:', err);
|
|
toast.error('Could not reach the verification endpoint.');
|
|
} finally {
|
|
setVerifyingRegistration(false);
|
|
}
|
|
}
|
|
|
|
async function handleReset() {
|
|
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setResetting(true);
|
|
const res = await fetch('/api/whatsapp/config', { method: 'DELETE' });
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
toast.error(data.error || 'Failed to reset configuration');
|
|
return;
|
|
}
|
|
|
|
toast.success('Configuration cleared. You can now re-enter your credentials.');
|
|
setConfig(null);
|
|
setPhoneNumberId('');
|
|
setWabaId('');
|
|
setAccessToken('');
|
|
setVerifyToken('');
|
|
setTokenEdited(false);
|
|
setConnectionStatus('disconnected');
|
|
setResetReason(null);
|
|
setStatusMessage('');
|
|
} catch (err) {
|
|
console.error('Reset error:', err);
|
|
toast.error('Failed to reset configuration');
|
|
} finally {
|
|
setResetting(false);
|
|
}
|
|
}
|
|
|
|
function handleCopyWebhookUrl() {
|
|
navigator.clipboard.writeText(webhookUrl);
|
|
toast.success('Webhook URL copied to clipboard');
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<section className="animate-in fade-in-50 duration-200">
|
|
<SettingsPanelHead
|
|
title="WhatsApp connection"
|
|
description="Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here."
|
|
/>
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="size-6 animate-spin text-primary" />
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const showResetBanner = resetReason === 'token_corrupted';
|
|
|
|
return (
|
|
<section className="animate-in fade-in-50 duration-200">
|
|
<SettingsPanelHead
|
|
title="WhatsApp connection"
|
|
description="Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here."
|
|
/>
|
|
<div className="grid gap-6 lg:grid-cols-[1fr_380px]">
|
|
{/* Main config form */}
|
|
<div className="space-y-6">
|
|
{/* Corrupted-token reset banner */}
|
|
{showResetBanner && (
|
|
<Alert className="bg-amber-950/40 border-amber-600/40">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="size-5 text-amber-400 mt-0.5 shrink-0" />
|
|
<div className="flex-1">
|
|
<AlertTitle className="text-amber-200 mb-1">
|
|
Stored token can't be decrypted
|
|
</AlertTitle>
|
|
<AlertDescription className="text-amber-100/80 text-sm">
|
|
{statusMessage}
|
|
</AlertDescription>
|
|
<Button
|
|
onClick={handleReset}
|
|
disabled={resetting}
|
|
size="sm"
|
|
className="mt-3 bg-amber-600 hover:bg-amber-700 text-white"
|
|
>
|
|
{resetting ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Resetting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<RotateCcw className="size-4" />
|
|
Reset Configuration
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Connection Status */}
|
|
<Alert className="bg-card border-border">
|
|
<div className="flex items-center gap-2">
|
|
{connectionStatus === 'connected' ? (
|
|
<CheckCircle2 className="size-4 text-primary" />
|
|
) : (
|
|
<XCircle className="size-4 text-red-500" />
|
|
)}
|
|
<AlertTitle className="text-foreground mb-0">
|
|
{connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
|
|
</AlertTitle>
|
|
</div>
|
|
<AlertDescription className="text-muted-foreground">
|
|
{connectionStatus === 'connected'
|
|
? 'Your access token authenticates with Meta. See Registration status below for whether webhooks are actually wired.'
|
|
: statusMessage ||
|
|
'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{/* Registration Status — the "is it actually live?" check.
|
|
Credentials being valid is necessary but not sufficient;
|
|
without a successful /register call the number won't
|
|
receive inbound events. Surface this dimension separately
|
|
so users don't trust a misleading green banner. */}
|
|
{config && (
|
|
<Alert
|
|
className={
|
|
isRegistered
|
|
? 'bg-emerald-950/30 border-emerald-700/50'
|
|
: 'bg-amber-950/30 border-amber-700/50'
|
|
}
|
|
>
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
{isRegistered ? (
|
|
<CheckCircle2 className="size-4 text-emerald-400" />
|
|
) : (
|
|
<AlertTriangle className="size-4 text-amber-400" />
|
|
)}
|
|
<AlertTitle
|
|
className={
|
|
'mb-0 ' + (isRegistered ? 'text-emerald-200' : 'text-amber-200')
|
|
}
|
|
>
|
|
{isRegistered
|
|
? 'Registered — Meta will deliver events to wacrm'
|
|
: 'Not registered — Meta will not deliver events'}
|
|
</AlertTitle>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleVerifyRegistration}
|
|
disabled={verifyingRegistration}
|
|
className="border-border bg-transparent text-foreground hover:bg-muted h-7"
|
|
>
|
|
{verifyingRegistration ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Zap className="size-3.5" />
|
|
)}
|
|
Verify with Meta
|
|
</Button>
|
|
</div>
|
|
<AlertDescription className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
|
{isRegistered ? (
|
|
<>
|
|
Subscribed since{' '}
|
|
{config.registered_at
|
|
? new Date(config.registered_at).toLocaleString()
|
|
: 'unknown'}
|
|
. Click <strong>Verify with Meta</strong> if events
|
|
stop arriving.
|
|
</>
|
|
) : lastRegistrationError ? (
|
|
<>
|
|
Last attempt failed with:{' '}
|
|
<span className="text-red-300">
|
|
"{lastRegistrationError}"
|
|
</span>
|
|
. Enter (or correct) the 2-step PIN below and click
|
|
Save Configuration to retry.
|
|
</>
|
|
) : (
|
|
<>
|
|
This number was saved before registration tracking
|
|
existed, or registration was skipped. Enter the
|
|
2-step PIN below and click Save Configuration to
|
|
subscribe it.
|
|
</>
|
|
)}
|
|
</AlertDescription>
|
|
|
|
{registrationProbe && (
|
|
<div className="mt-3 rounded border border-border bg-card/60 px-3 py-2 space-y-1.5 text-[11px]">
|
|
<p className="font-medium text-foreground">
|
|
Diagnostic — last run: {' '}
|
|
<span className={registrationProbe.live ? 'text-emerald-400' : 'text-amber-400'}>
|
|
{registrationProbe.live ? 'live' : 'not live'}
|
|
</span>
|
|
</p>
|
|
<ul className="space-y-0.5 text-muted-foreground">
|
|
{Object.entries(registrationProbe.checks).map(([k, v]) => (
|
|
<li key={k} className="flex items-center gap-1.5">
|
|
{v === true ? (
|
|
<CheckCircle2 className="size-3 text-emerald-400 shrink-0" />
|
|
) : v === false ? (
|
|
<XCircle className="size-3 text-red-400 shrink-0" />
|
|
) : (
|
|
<span className="size-3 rounded-full border border-border shrink-0" />
|
|
)}
|
|
<code className="text-muted-foreground">{k}</code>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{(registrationProbe.errors ?? []).length > 0 && (
|
|
<ul className="pt-1 space-y-0.5 text-red-300">
|
|
{registrationProbe.errors?.map((e, i) => (
|
|
<li key={i}>• {e}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* API Credentials */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-foreground">API Credentials</CardTitle>
|
|
<CardDescription className="text-muted-foreground">
|
|
Enter your Meta WhatsApp Business API credentials.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">Phone Number ID</Label>
|
|
<Input
|
|
placeholder="e.g. 100234567890123"
|
|
value={phoneNumberId}
|
|
onChange={(e) => setPhoneNumberId(e.target.value)}
|
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">WhatsApp Business Account ID</Label>
|
|
<Input
|
|
placeholder="e.g. 100234567890456"
|
|
value={wabaId}
|
|
onChange={(e) => setWabaId(e.target.value)}
|
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">Permanent Access Token</Label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showToken ? 'text' : 'password'}
|
|
placeholder="Enter your access token"
|
|
value={accessToken}
|
|
onChange={(e) => {
|
|
setAccessToken(e.target.value);
|
|
setTokenEdited(true);
|
|
}}
|
|
onFocus={() => {
|
|
if (accessToken === MASKED_TOKEN) {
|
|
setAccessToken('');
|
|
setTokenEdited(true);
|
|
}
|
|
}}
|
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowToken(!showToken)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{showToken ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
</button>
|
|
</div>
|
|
{config && !tokenEdited && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Token is hidden for security. Re-enter it to update configuration.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">Webhook Verify Token</Label>
|
|
<Input
|
|
placeholder="Create a custom verify token"
|
|
value={verifyToken}
|
|
onChange={(e) => setVerifyToken(e.target.value)}
|
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
A custom string you create. Must match the token you set in Meta webhook settings.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">
|
|
Two-step verification PIN
|
|
<span className="ml-1 text-muted-foreground">(optional)</span>
|
|
</Label>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={6}
|
|
placeholder="6-digit PIN from Meta WhatsApp Manager"
|
|
value={pin}
|
|
onChange={(e) =>
|
|
setPin(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
}
|
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground tracking-widest"
|
|
/>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
Needed only to wire <strong className="text-muted-foreground">inbound</strong> messages
|
|
for a <strong className="text-muted-foreground">production</strong> number. Set it in{' '}
|
|
<strong className="text-muted-foreground">
|
|
Meta Business Manager → WhatsApp Accounts → Phone
|
|
Numbers → Two-step verification
|
|
</strong>
|
|
, then paste it here so wacrm can subscribe the number —
|
|
otherwise Meta routes inbound events to whichever app
|
|
last claimed it (the symptom that hits second numbers
|
|
under a shared WABA).{' '}
|
|
<strong className="text-muted-foreground">Meta test numbers</strong> have no
|
|
PIN and are pre-registered — leave this blank for them.
|
|
Leaving it blank also keeps an existing registration
|
|
untouched.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Webhook URL */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-foreground">Webhook Configuration</CardTitle>
|
|
<CardDescription className="text-muted-foreground">
|
|
Use this URL as your webhook callback in the Meta App Dashboard.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">Webhook Callback URL</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
readOnly
|
|
value={webhookUrl}
|
|
className="bg-muted border-border text-muted-foreground font-mono text-sm"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleCopyWebhookUrl}
|
|
className="shrink-0 border-border text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
>
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save Configuration'
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleTestConnection}
|
|
disabled={testing || !config}
|
|
className="border-border text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
>
|
|
{testing ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Testing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="size-4" />
|
|
Test API Connection
|
|
</>
|
|
)}
|
|
</Button>
|
|
{config && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleReset}
|
|
disabled={resetting}
|
|
className="border-red-900 text-red-400 hover:text-red-300 hover:bg-red-950/40"
|
|
>
|
|
{resetting ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Resetting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<RotateCcw className="size-4" />
|
|
Reset Configuration
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Setup Instructions Sidebar */}
|
|
<div>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-foreground text-base">Setup Instructions</CardTitle>
|
|
<CardDescription className="text-muted-foreground">
|
|
Follow these steps to connect your WhatsApp Business API.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Accordion>
|
|
<AccordionItem className="border-border">
|
|
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
|
|
<span className="flex items-center gap-2">
|
|
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">1</span>
|
|
Create a Meta App
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="text-muted-foreground">
|
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
|
<li>Go to <span className="text-primary">developers.facebook.com</span></li>
|
|
<li>Click "My Apps" and then "Create App"</li>
|
|
<li>Select "Business" as the app type</li>
|
|
<li>Fill in app details and create</li>
|
|
</ol>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
<AccordionItem className="border-border">
|
|
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
|
|
<span className="flex items-center gap-2">
|
|
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">2</span>
|
|
Add WhatsApp Product
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="text-muted-foreground">
|
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
|
<li>In your app dashboard, click "Add Product"</li>
|
|
<li>Find "WhatsApp" and click "Set Up"</li>
|
|
<li>Follow the setup wizard to link your business</li>
|
|
</ol>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
<AccordionItem className="border-border">
|
|
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
|
|
<span className="flex items-center gap-2">
|
|
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">3</span>
|
|
Get API Credentials
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="text-muted-foreground">
|
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
|
<li>Go to WhatsApp > API Setup</li>
|
|
<li>Copy your <strong className="text-foreground">Phone Number ID</strong></li>
|
|
<li>Copy your <strong className="text-foreground">WhatsApp Business Account ID</strong></li>
|
|
<li>Generate a <strong className="text-foreground">Permanent Access Token</strong> from Business Settings > System Users</li>
|
|
</ol>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
|
|
<AccordionItem className="border-border">
|
|
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
|
|
<span className="flex items-center gap-2">
|
|
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">4</span>
|
|
Configure Webhooks
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="text-muted-foreground">
|
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
|
<li>Go to WhatsApp > Configuration</li>
|
|
<li>Click "Edit" on the Webhook section</li>
|
|
<li>Paste the <strong className="text-foreground">Webhook Callback URL</strong> from above</li>
|
|
<li>Enter the same <strong className="text-foreground">Verify Token</strong> you set here</li>
|
|
<li>Subscribe to "messages" webhook field</li>
|
|
</ol>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
|
|
<div className="mt-4 pt-4 border-t border-border">
|
|
<a
|
|
href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
Meta WhatsApp API Documentation
|
|
</a>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|