- Add Sales/Commissions routes to router (11 new routes) - Complete authStore (refreshTokens, updateProfile methods) - Add JSDoc documentation to 40+ components across all categories - Create FRONTEND-ROUTING.md with complete route mapping - Create FRONTEND-PAGES-SPEC.md with 38 page specifications - Update FRONTEND_INVENTORY.yml to v4.1.0 with resolved gaps Components documented: ai/, audit/, commissions/, feature-flags/, notifications/, sales/, storage/, webhooks/, whatsapp/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Webhook, CreateWebhookRequest, UpdateWebhookRequest, WebhookEvent } from '@/services/api';
|
|
import { X, Plus, Trash2, Eye, EyeOff, Copy, Check } from 'lucide-react';
|
|
import clsx from 'clsx';
|
|
|
|
/**
|
|
* Props for the WebhookForm component.
|
|
*/
|
|
interface WebhookFormProps {
|
|
/** Existing webhook data for editing, or null/undefined for creating a new webhook */
|
|
webhook?: Webhook | null;
|
|
/** List of available webhook events that can be subscribed to */
|
|
events: WebhookEvent[];
|
|
/** Callback invoked when the form is submitted with valid data */
|
|
onSubmit: (data: CreateWebhookRequest | UpdateWebhookRequest) => void;
|
|
/** Callback invoked when the user cancels the form */
|
|
onCancel: () => void;
|
|
/** Whether the form submission is in progress */
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Form component for creating or editing webhook configurations.
|
|
*
|
|
* @description Provides a comprehensive form for webhook management including:
|
|
* - Name and description fields
|
|
* - HTTPS endpoint URL with validation
|
|
* - Event selection checkboxes
|
|
* - Custom HTTP headers management
|
|
* - Signing secret display (edit mode only)
|
|
*
|
|
* @param props - The component props
|
|
* @param props.webhook - Existing webhook data for editing, or null for creating
|
|
* @param props.events - List of available webhook events to subscribe to
|
|
* @param props.onSubmit - Callback when form is submitted with valid data
|
|
* @param props.onCancel - Callback when user cancels the form
|
|
* @param props.isLoading - Whether submission is in progress (disables form)
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Creating a new webhook
|
|
* <WebhookForm
|
|
* events={availableEvents}
|
|
* onSubmit={(data) => createWebhook(data)}
|
|
* onCancel={() => closeModal()}
|
|
* />
|
|
*
|
|
* // Editing an existing webhook
|
|
* <WebhookForm
|
|
* webhook={existingWebhook}
|
|
* events={availableEvents}
|
|
* onSubmit={(data) => updateWebhook(existingWebhook.id, data)}
|
|
* onCancel={() => closeModal()}
|
|
* isLoading={isSaving}
|
|
* />
|
|
* ```
|
|
*/
|
|
export function WebhookForm({
|
|
webhook,
|
|
events,
|
|
onSubmit,
|
|
onCancel,
|
|
isLoading,
|
|
}: WebhookFormProps) {
|
|
const [name, setName] = useState(webhook?.name || '');
|
|
const [description, setDescription] = useState(webhook?.description || '');
|
|
const [url, setUrl] = useState(webhook?.url || '');
|
|
const [selectedEvents, setSelectedEvents] = useState<string[]>(webhook?.events || []);
|
|
const [headers, setHeaders] = useState<{ key: string; value: string }[]>(
|
|
webhook?.headers
|
|
? Object.entries(webhook.headers).map(([key, value]) => ({ key, value }))
|
|
: []
|
|
);
|
|
const [showSecret, setShowSecret] = useState(false);
|
|
const [secretCopied, setSecretCopied] = useState(false);
|
|
|
|
const isEditing = !!webhook;
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
const headersObj = headers.reduce((acc, { key, value }) => {
|
|
if (key.trim()) {
|
|
acc[key.trim()] = value;
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, string>);
|
|
|
|
const data = {
|
|
name,
|
|
description: description || undefined,
|
|
url,
|
|
events: selectedEvents,
|
|
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
|
|
};
|
|
|
|
onSubmit(data);
|
|
};
|
|
|
|
const toggleEvent = (eventName: string) => {
|
|
setSelectedEvents((prev) =>
|
|
prev.includes(eventName)
|
|
? prev.filter((e) => e !== eventName)
|
|
: [...prev, eventName]
|
|
);
|
|
};
|
|
|
|
const addHeader = () => {
|
|
setHeaders((prev) => [...prev, { key: '', value: '' }]);
|
|
};
|
|
|
|
const removeHeader = (index: number) => {
|
|
setHeaders((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
|
setHeaders((prev) =>
|
|
prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))
|
|
);
|
|
};
|
|
|
|
const copySecret = async () => {
|
|
if (webhook?.secret) {
|
|
await navigator.clipboard.writeText(webhook.secret);
|
|
setSecretCopied(true);
|
|
setTimeout(() => setSecretCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const isValid = name.trim() && url.trim() && url.startsWith('https://') && selectedEvents.length > 0;
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="My Webhook"
|
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
Description
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Optional description"
|
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* URL */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
Endpoint URL *
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://example.com/webhook"
|
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
|
|
required
|
|
/>
|
|
{url && !url.startsWith('https://') && (
|
|
<p className="mt-1 text-sm text-red-600">URL must use HTTPS</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Secret (only for editing) */}
|
|
{isEditing && webhook?.secret && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
Signing Secret
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 relative">
|
|
<input
|
|
type={showSecret ? 'text' : 'password'}
|
|
value={webhook.secret}
|
|
readOnly
|
|
className="w-full px-3 py-2 pr-20 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-secondary-100 font-mono text-sm"
|
|
/>
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSecret(!showSecret)}
|
|
className="p-1 text-secondary-400 hover:text-secondary-600"
|
|
>
|
|
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={copySecret}
|
|
className="p-1 text-secondary-400 hover:text-secondary-600"
|
|
>
|
|
{secretCopied ? (
|
|
<Check className="w-4 h-4 text-green-500" />
|
|
) : (
|
|
<Copy className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p className="mt-1 text-xs text-secondary-500">
|
|
Use this secret to verify webhook signatures
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Events */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
|
Events * ({selectedEvents.length} selected)
|
|
</label>
|
|
<div className="border border-secondary-300 dark:border-secondary-600 rounded-lg p-3 max-h-48 overflow-y-auto">
|
|
<div className="space-y-2">
|
|
{events.map((event) => (
|
|
<label
|
|
key={event.name}
|
|
className="flex items-start gap-3 p-2 rounded hover:bg-secondary-50 dark:hover:bg-secondary-700/50 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedEvents.includes(event.name)}
|
|
onChange={() => toggleEvent(event.name)}
|
|
className="mt-0.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<div>
|
|
<div className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
|
{event.name}
|
|
</div>
|
|
<div className="text-xs text-secondary-500">{event.description}</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{selectedEvents.length === 0 && (
|
|
<p className="mt-1 text-sm text-red-600">Select at least one event</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Custom Headers */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
Custom Headers
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={addHeader}
|
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Header
|
|
</button>
|
|
</div>
|
|
{headers.length > 0 && (
|
|
<div className="space-y-2">
|
|
{headers.map((header, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={header.key}
|
|
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
|
placeholder="Header name"
|
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={header.value}
|
|
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
|
placeholder="Value"
|
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeHeader(index)}
|
|
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{headers.length === 0 && (
|
|
<p className="text-sm text-secondary-500">No custom headers</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!isValid || isLoading}
|
|
className={clsx(
|
|
'px-4 py-2 rounded-lg font-medium',
|
|
isValid && !isLoading
|
|
? 'bg-primary-600 text-white hover:bg-primary-700'
|
|
: 'bg-secondary-200 text-secondary-400 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isLoading ? 'Saving...' : isEditing ? 'Update Webhook' : 'Create Webhook'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|