refactor(wholesale): split 2391-line WholesaleClient into focused modules
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.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import { useState } from "react";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
import {
|
||||
type WholesaleSettings,
|
||||
type NotificationRecipient,
|
||||
saveWholesaleSettings,
|
||||
} from "@/actions/wholesale";
|
||||
import AddRecipientForm from "./AddRecipientForm";
|
||||
import WebhookSettingsSection from "./WebhookSettingsSection";
|
||||
import type { MsgFn } from "./types";
|
||||
|
||||
interface SettingsTabProps {
|
||||
settings: WholesaleSettings | null;
|
||||
brandId: string;
|
||||
onMsg: MsgFn;
|
||||
onRefresh: () => void;
|
||||
canManageSettings: boolean;
|
||||
}
|
||||
|
||||
// Wholesale portal settings: approval flow, payments, pickup location, invoice
|
||||
// details, Square sync toggle, team notification recipients, and outbound webhook.
|
||||
export default function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }: SettingsTabProps) {
|
||||
// Hooks must be called unconditionally - always declare them first
|
||||
const [form, setForm] = useState({
|
||||
requireApproval: settings?.require_approval ?? true,
|
||||
minOrderAmount: settings?.min_order_amount ?? "",
|
||||
onlinePaymentEnabled: settings?.online_payment_enabled ?? false,
|
||||
wholesaleEnabled: settings?.wholesale_enabled ?? true,
|
||||
squareSyncEnabled: settings?.square_sync_enabled ?? false,
|
||||
pickupLocation: settings?.pickup_location ?? "",
|
||||
fobLocation: settings?.fob_location ?? "",
|
||||
fromEmail: settings?.from_email ?? "",
|
||||
invoiceBusinessName: settings?.invoice_business_name ?? "",
|
||||
invoiceBusinessAddress: settings?.invoice_business_address ?? "",
|
||||
invoiceBusinessPhone: settings?.invoice_business_phone ?? "",
|
||||
invoiceBusinessEmail: settings?.invoice_business_email ?? "",
|
||||
invoiceBusinessWebsite: settings?.invoice_business_website ?? "",
|
||||
notificationRecipients: settings?.notification_recipients ?? [],
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Permission check after hooks
|
||||
if (!canManageSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-slate-400">You do not have permission to manage settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const result = await saveWholesaleSettings({
|
||||
brandId,
|
||||
requireApproval: form.requireApproval,
|
||||
minOrderAmount: form.minOrderAmount ? Number(form.minOrderAmount) : undefined,
|
||||
onlinePaymentEnabled: form.onlinePaymentEnabled,
|
||||
wholesaleEnabled: form.wholesaleEnabled,
|
||||
squareSyncEnabled: form.squareSyncEnabled,
|
||||
pickupLocation: form.pickupLocation,
|
||||
fobLocation: form.fobLocation,
|
||||
fromEmail: form.fromEmail,
|
||||
invoiceBusinessName: form.invoiceBusinessName,
|
||||
invoiceBusinessAddress: form.invoiceBusinessAddress,
|
||||
invoiceBusinessPhone: form.invoiceBusinessPhone,
|
||||
invoiceBusinessWebsite: form.invoiceBusinessWebsite,
|
||||
notificationRecipients: form.notificationRecipients,
|
||||
});
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
onMsg("success", "Settings saved.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to save settings.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification Recipients helpers ────────────────────────────────────────
|
||||
function addRecipient(email: string, name: string = "") {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: [
|
||||
...f.notificationRecipients,
|
||||
{ email, name, active: true },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function removeRecipient(idx: number) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: f.notificationRecipients.filter((_: unknown, i: number) => i !== idx),
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleRecipient(idx: number) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
|
||||
i === idx ? { ...r, active: !r.active } : r
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateRecipientName(idx: number, name: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
|
||||
i === idx ? { ...r, name } : r
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Settings</h2>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Require Approval</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">New registrations must be manually approved.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setForm(f => ({ ...f, requireApproval: !f.requireApproval }))}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.requireApproval ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.requireApproval ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand's storefront page.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setForm(f => ({ ...f, wholesaleEnabled: !f.wholesaleEnabled }))}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.wholesaleEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.wholesaleEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent-light)]">
|
||||
<svg className="h-4 w-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Square Sync for Wholesale</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
When enabled, wholesale product changes are automatically pushed to Square.
|
||||
{form.squareSyncEnabled
|
||||
? " Auto-sync is active."
|
||||
: " Auto-sync is disabled — changes will not be pushed to Square."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setForm(f => ({ ...f, squareSyncEnabled: !f.squareSyncEnabled }))}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.squareSyncEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.squareSyncEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Minimum Order Amount ($)</label>
|
||||
<input type="number" value={form.minOrderAmount} onChange={e => setForm(f => ({ ...f, minOrderAmount: e.target.value }))}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="No minimum" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">From Email</label>
|
||||
<input type="email" value={form.fromEmail} onChange={e => setForm(f => ({ ...f, fromEmail: e.target.value }))}
|
||||
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 className="col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Pickup Location</label>
|
||||
<textarea value={form.pickupLocation} onChange={e => setForm(f => ({ ...f, pickupLocation: e.target.value }))}
|
||||
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" rows={3} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">FOB Location</label>
|
||||
<input value={form.fobLocation} onChange={e => setForm(f => ({ ...f, fobLocation: e.target.value }))}
|
||||
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 className="col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Name</label>
|
||||
<input value={form.invoiceBusinessName} onChange={e => setForm(f => ({ ...f, invoiceBusinessName: e.target.value }))}
|
||||
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 className="col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Address</label>
|
||||
<textarea value={form.invoiceBusinessAddress} onChange={e => setForm(f => ({ ...f, invoiceBusinessAddress: e.target.value }))}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Phone</label>
|
||||
<input value={form.invoiceBusinessPhone} onChange={e => setForm(f => ({ ...f, invoiceBusinessPhone: e.target.value }))}
|
||||
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">Invoice Business Email</label>
|
||||
<input type="email" value={form.invoiceBusinessEmail} onChange={e => setForm(f => ({ ...f, invoiceBusinessEmail: e.target.value }))}
|
||||
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 className="col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Website</label>
|
||||
<input value={form.invoiceBusinessWebsite} onChange={e => setForm(f => ({ ...f, invoiceBusinessWebsite: e.target.value }))}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="https://" />
|
||||
</div>
|
||||
|
||||
{/* Team Notification Recipients */}
|
||||
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1">Team Notification Recipients</label>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
|
||||
These team members receive all wholesale notifications for this brand — new orders,
|
||||
deposits, fulfillments, price sheets, and pickup reminders. If no recipients are
|
||||
active, the system falls back to any configured notification email on file.
|
||||
</p>
|
||||
|
||||
{/* Empty state */}
|
||||
{form.notificationRecipients.length === 0 && (
|
||||
<div className="text-center py-6 mb-4 rounded-xl bg-white ring-1 ring-[var(--admin-border)]">
|
||||
<p className="text-sm text-[var(--admin-text-muted)] mb-1">No recipients added yet.</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Add an email address below to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipients list */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{form.notificationRecipients.map((r: NotificationRecipient, idx: number) => (
|
||||
<div key={idx} className={`flex items-center gap-2 rounded-xl px-3 py-2.5 bg-white ring-1 ${r.active ? "ring-[var(--admin-border)]" : "ring-[var(--admin-border-light)] opacity-70"}`}>
|
||||
<input
|
||||
type="checkbox" checked={r.active} onChange={() => toggleRecipient(idx)}
|
||||
className="rounded border-[var(--admin-border)] mt-0.5" title={r.active ? "Active — receives notifications" : "Inactive"} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${r.active ? "text-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"}`}>{r.email}</p>
|
||||
{r.name && <p className="text-xs text-[var(--admin-text-muted)] truncate">{r.name}</p>}
|
||||
{r.notification_types && r.notification_types.length > 0 && (
|
||||
<p className="text-xs text-[var(--admin-text-secondary)] mt-0.5" title="Per-type filtering coming soon">Types: {r.notification_types.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text" placeholder="Name (optional)"
|
||||
value={r.name ?? ""}
|
||||
onChange={e => updateRecipientName(idx, e.target.value)}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-2.5 py-1.5 text-xs w-36 outline-none focus:border-[var(--admin-accent)]" />
|
||||
<button onClick={() => removeRecipient(idx)}
|
||||
className="text-[var(--admin-danger)] hover:text-[var(--admin-danger)] text-xs font-medium px-2 py-1 rounded-lg hover:bg-[var(--admin-danger-light)] transition-colors"
|
||||
title="Remove recipient">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add recipient */}
|
||||
<AddRecipientForm onAdd={addRecipient} />
|
||||
</div>
|
||||
{/* Webhook Settings */}
|
||||
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1">Webhook Integration</label>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
|
||||
Send order events to external systems (Harvest Point, ERPs, etc.). Payload is signed
|
||||
with HMAC-SHA256. Enable and configure the URL and secret below.
|
||||
</p>
|
||||
<WebhookSettingsSection brandId={brandId} onMsg={onMsg} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user