f7ac9399b2
Add aria-label to buttons (icon-only, pagination, action) and inputs (placeholder-derived) via balanced-brace JSX parser. Files with complex onClick expressions (deep brace nesting) skipped to avoid breakage.
287 lines
17 KiB
TypeScript
287 lines
17 KiB
TypeScript
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 type="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 type="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 type="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 htmlFor="fld-1-minimum-order-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Minimum Order Amount ($)</label>
|
|
<input id="fld-1-minimum-order-amount" 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" aria-label="No minimum"/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="fld-2-from-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">From Email</label>
|
|
<input id="fld-2-from-email" 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 htmlFor="fld-3-pickup-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Pickup Location</label>
|
|
<textarea id="fld-3-pickup-location" 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 htmlFor="fld-4-fob-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">FOB Location</label>
|
|
<input id="fld-4-fob-location" 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 htmlFor="fld-5-invoice-business-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Name</label>
|
|
<input id="fld-5-invoice-business-name" 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 htmlFor="fld-6-invoice-business-address" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Address</label>
|
|
<textarea id="fld-6-invoice-business-address" 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 htmlFor="fld-7-invoice-business-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Phone</label>
|
|
<input id="fld-7-invoice-business-phone" 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 htmlFor="fld-8-invoice-business-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Email</label>
|
|
<input id="fld-8-invoice-business-email" 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 htmlFor="fld-9-invoice-business-website" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Website</label>
|
|
<input id="fld-9-invoice-business-website" 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://" aria-label="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" htmlFor="fld-team-notification-recipients">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={r.email} 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 id="fld-team-notification-recipients"
|
|
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)]" aria-label="Name (optional)"/>
|
|
<button type="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" aria-label="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" htmlFor="fld-webhook-integration">Webhook Integration</label><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>
|
|
);
|
|
} |