880c52227a
The Wholesale Portal admin client was the largest single file in the app (2391 lines) and mixed 11 distinct UI concerns in one module. Split into focused files under src/components/wholesale/admin/: types.ts shared types (MsgFn, PendingRegistration, ...) WholesaleIcon.tsx header SVG WholesaleLoadingSkeleton.tsx loading state StatusBadge.tsx order status pill AddRecipientForm.tsx notification recipient input PriceSheetModal.tsx bulk price-sheet send confirmation CustomerPricingPanel.tsx per-customer pricing overrides WebhookSettingsSection.tsx outbound webhook config + test dispatch DashboardTab.tsx stat cards + recent orders + webhook feed ProductsTab.tsx wholesale product CRUD CustomersTab.tsx customers + registrations + pricing OrdersTab.tsx orders + bulk ops + deposit modals SettingsTab.tsx portal settings + webhook + recipients WholesaleClient.tsx is now a 189-line shell that owns data loading and tab routing. No behavior changes; the original logic was preserved verbatim. Mechanical extraction only. Also fixed an existing import bug where getPendingWholesaleRegistrations was being imported from @/actions/wholesale instead of @/actions/wholesale-register (typecheck caught it). Verified: typecheck clean for new code (pre-existing dahlia/fetch mock errors unrelated). Build compiles through to the same pre-existing billing type error. Zero new lint warnings introduced. Refactor-progress tracked at /tmp/refactor-routecomm.md.
167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import crypto from "crypto";
|
|
import { AdminButton } from "@/components/admin/design-system";
|
|
import { getWebhookSettings, saveWebhookSettings } from "@/actions/wholesale";
|
|
import type { MsgFn } from "./types";
|
|
|
|
interface WebhookSettingsSectionProps {
|
|
brandId: string;
|
|
onMsg: MsgFn;
|
|
}
|
|
|
|
// Section inside SettingsTab that lets admins configure an outbound HMAC-signed
|
|
// webhook for wholesale order events. Includes a "Send Test Webhook" button that
|
|
// dispatches a signed payload to the configured URL.
|
|
export default function WebhookSettingsSection({ brandId, onMsg }: WebhookSettingsSectionProps) {
|
|
const [settings, setSettings] = useState<{
|
|
url: string;
|
|
secret: string;
|
|
enabled: boolean;
|
|
}>({ url: "", secret: "", enabled: false });
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
getWebhookSettings(brandId).then((s: { url: string; secret: string; enabled: boolean } | null) => {
|
|
if (s) {
|
|
setSettings({ url: s.url ?? "", secret: s.secret ?? "", enabled: s.enabled });
|
|
} else {
|
|
setSettings({ url: "", secret: "", enabled: false });
|
|
}
|
|
setLoading(false);
|
|
});
|
|
}, [brandId]);
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
const result = await saveWebhookSettings({
|
|
brandId,
|
|
url: settings.url,
|
|
secret: settings.secret,
|
|
enabled: settings.enabled,
|
|
});
|
|
setSaving(false);
|
|
if (result.success) {
|
|
onMsg("success", "Webhook settings saved.");
|
|
} else {
|
|
onMsg("error", result.error ?? "Failed to save webhook settings.");
|
|
}
|
|
}
|
|
|
|
async function handleTestDispatch() {
|
|
if (!settings.url || !settings.secret) {
|
|
onMsg("error", "Enter both a webhook URL and signing secret before testing.");
|
|
return;
|
|
}
|
|
setTesting(true);
|
|
onMsg("success", "Sending test webhook...");
|
|
|
|
const testPayload = {
|
|
event: "order_created",
|
|
test: true,
|
|
brand_id: brandId,
|
|
message: "Test webhook from Route Commerce wholesale portal",
|
|
timestamp: new Date().toISOString(),
|
|
order_id: null,
|
|
};
|
|
const payloadString = JSON.stringify(testPayload);
|
|
const signature = crypto
|
|
.createHmac("sha256", settings.secret)
|
|
.update(payloadString)
|
|
.digest("hex");
|
|
|
|
try {
|
|
const res = await fetch(settings.url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Webhook-Signature": `sha256=${signature}`,
|
|
"X-Webhook-Event": "order_created",
|
|
},
|
|
body: payloadString,
|
|
});
|
|
const responseText = await res.text().catch(() => "");
|
|
if (res.ok) {
|
|
onMsg("success", `Test delivered — ${res.status} response from your endpoint. Check wholesale_sync_log for the logged entry.`);
|
|
} else {
|
|
onMsg("error", `Webhook endpoint returned ${res.status}: ${responseText.slice(0, 120)}`);
|
|
}
|
|
} catch (err) {
|
|
onMsg("error", `Connection failed: ${err instanceof Error ? err.message : "Network error"}`);
|
|
}
|
|
|
|
setTesting(false);
|
|
}
|
|
|
|
if (loading) {
|
|
return <p className="text-sm text-slate-400">Loading webhook settings...</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Webhook Enabled</p>
|
|
<p className="text-xs text-[var(--admin-text-muted)]">When disabled, no events are queued or sent.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setSettings(s => ({ ...s, enabled: !s.enabled }))}
|
|
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${settings.enabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
|
>
|
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${settings.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Webhook URL</label>
|
|
<input
|
|
type="url"
|
|
value={settings.url}
|
|
onChange={e => setSettings(s => ({ ...s, url: e.target.value }))}
|
|
placeholder="https://your-system.com/webhooks/wholesale"
|
|
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">
|
|
Signing Secret (HMAC-SHA256)
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={settings.secret}
|
|
onChange={e => setSettings(s => ({ ...s, secret: e.target.value }))}
|
|
placeholder="Leave blank to keep current secret"
|
|
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono"
|
|
/>
|
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
|
The receiver should verify the <code className="bg-[var(--admin-bg-subtle)] px-1">X-Webhook-Signature</code> header using this secret.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 pt-1">
|
|
<AdminButton
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
variant="primary"
|
|
size="sm"
|
|
isLoading={saving}
|
|
>
|
|
{saving ? "Saving..." : "Save Webhook Settings"}
|
|
</AdminButton>
|
|
{settings.enabled && settings.url && (
|
|
<AdminButton
|
|
onClick={handleTestDispatch}
|
|
disabled={testing}
|
|
variant="secondary"
|
|
size="sm"
|
|
isLoading={testing}
|
|
>
|
|
{testing ? "Dispatching..." : "Send Test Webhook"}
|
|
</AdminButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |