fix: react-doctor unused-file 123→27 (-96 dead files removed)

This commit is contained in:
Nora
2026-06-26 04:38:44 -06:00
parent ab9813b4ee
commit 27b2ded94e
96 changed files with 0 additions and 17319 deletions
-36
View File
@@ -1,36 +0,0 @@
/**
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
action: text("action").notNull(),
targetType: text("target_type"),
targetId: uuid("target_id"),
payload: jsonb("payload"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt),
}),
);
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
-56
View File
@@ -1,56 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
type AdminActionPayload = {
action_type: "create" | "update" | "delete";
admin_id?: string;
admin_email?: string;
affected_user_id?: string;
brand_id?: string;
details?: Record<string, unknown>;
};
type UserActivityPayload = {
user_id: string;
activity_type: "login" | "logout" | "password_change" | "profile_update" | "email_change";
details?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
};
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
await getSession(); try {
await pool.query("SELECT log_admin_action($1::jsonb)", [
JSON.stringify({
action_type: payload.action_type,
admin_id: payload.admin_id ?? null,
admin_email: payload.admin_email ?? null,
affected_user_id: payload.affected_user_id ?? null,
brand_id: payload.brand_id ?? null,
details: payload.details ?? {},
}),
]);
} catch {
// logging failed silently
}
}
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
await getSession(); try {
await pool.query("SELECT log_user_activity($1::jsonb)", [
JSON.stringify({
user_id: payload.user_id,
activity_type: payload.activity_type,
details: payload.details ?? {},
ip_address: payload.ip_address ?? null,
user_agent: payload.user_agent ?? null,
}),
]);
} catch {
// logging failed silently
}
}
-215
View File
@@ -1,215 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
import { getSession } from "@/lib/auth";
function getSquareBaseUrl() {
return process.env.SQUARE_ENVIRONMENT === "production"
? "https://connect.squareup.com"
: "https://connect.squareupsandbox.com";
}
async function getSquareCatalogItemVariation(
accessToken: string,
catalogObjectId: string
) {
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
}
);
if (!response.ok) throw new Error(`Catalog object fetch failed: ${await response.text()}`);
return response.json();
}
async function batchUpdateSquareInventory(
accessToken: string,
locationId: string,
updates: Array<{
catalogObjectId: string;
quantity: number;
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
}>
) {
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/inventory/changes/batch-create`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
body: JSON.stringify({
idempotency_key: crypto.randomUUID(),
changes: updates.map((u) => ({
type: "ADJUSTMENT",
physical_count: {
catalog_object_id: u.catalogObjectId,
location_id: locationId,
quantity: String(u.quantity),
measurement_unit: { quantity_unit: u.type },
},
occurred_at: new Date().toISOString(),
})),
}),
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Batch inventory update failed: ${err}`);
}
return response.json();
}
export type SyncResult = {
success: boolean;
synced: number;
errors: string[];
};
/**
* Sync inventory from Route Commerce products TO Square.
* Reduces Square inventory for each product sold.
* Called after an RC order is placed or updated.
*
* @param brandId - brand to sync for
* @param items - array of { productId, quantity } representing sold items
*/
export async function syncInventoryToSquare(
brandId: string,
items: Array<{ productId: string; quantity: number }>
): Promise<SyncResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
const settingsResult = await getPaymentSettings(brandId);
if (!settingsResult.success || !settingsResult.settings) {
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
}
const settings = settingsResult.settings;
if (
!settings.square_access_token ||
!settings.square_location_id ||
!settings.square_sync_enabled ||
!["rc_to_square", "bidirectional"].includes(settings.square_inventory_mode)
) {
return { success: true, synced: 0, errors: [] }; // Not enabled, no-op
}
const errors: string[] = [];
const synced = 0;
// Build product name → quantity map
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
try {
// Fetch product details from RC
const productIds = items.map((i) => i.productId);
const rows = await withBrand(brandId, (db) =>
db
.select({ id: products.id, name: products.name })
.from(products)
.where(inArray(products.id, productIds))
);
if (rows.length === 0 && productIds.length > 0) {
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
}
// Find Square catalog items matching product names and reduce quantity
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
for (const product of rows) {
const qty = itemQtyMap.get(product.id) ?? 0;
if (qty <= 0) continue;
// In a real implementation, we'd maintain a mapping of RC product ID → Square catalog object ID
// For now, we skip this without the mapping (requires manual catalog linking)
// A future improvement would store square_catalog_object_id on RC products table
errors.push(`Product "${product.name}": inventory sync requires Square catalog object ID mapping (not yet implemented)`);
}
} catch (err) {
errors.push(`Inventory sync error: ${String(err)}`);
}
return { success: errors.length === 0, synced, errors };
}
/**
* Sync inventory from Square TO Route Commerce (polling).
* Fetches Square inventory counts and updates RC product inventory.
*/
export async function syncInventoryFromSquare(brandId: string): Promise<SyncResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
}
const settingsResult = await getPaymentSettings(brandId);
if (!settingsResult.success || !settingsResult.settings) {
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
}
const settings = settingsResult.settings;
if (
!settings.square_access_token ||
!settings.square_location_id ||
!settings.square_sync_enabled ||
!["square_to_rc", "bidirectional"].includes(settings.square_inventory_mode)
) {
return { success: true, synced: 0, errors: [] }; // Not enabled, no-op
}
const errors: string[] = [];
const synced = 0;
try {
// Fetch Square inventory counts for the location
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
{
method: "GET",
headers: {
Authorization: `Bearer ${settings.square_access_token}`,
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
}
);
if (!response.ok) {
return { success: false, synced: 0, errors: [`Square inventory fetch failed: ${await response.text()}`] };
}
const data = await response.json();
// For each inventory count, update RC product if we have a mapping
for (const count of data.counts ?? []) {
// count.catalog_object_id, count.quantity, count.calculated_at
// Would need a mapping table to update correct RC product
// This is noted as a future enhancement
errors.push(`Inventory item ${count.catalog_object_id}: requires Square catalog object ID mapping`);
}
} catch (err) {
errors.push(`Inventory sync error: ${String(err)}`);
}
return { success: errors.length === 0, synced, errors };
}
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { updateBrandPlanTier } from "@/actions/billing/stripe-portal";
type Props = {
currentTier: string;
brandId: string;
hasStripeCustomer: boolean;
};
const TIERS = [
{ value: "starter", label: "Starter", color: "bg-zinc-950 text-zinc-300" },
{ value: "farm", label: "Farm", color: "bg-blue-900/40 text-blue-700" },
{ value: "enterprise", label: "Enterprise", color: "bg-violet-100 text-violet-700" },
];
export default function BillingClient({ currentTier, brandId }: Props) {
// Uncontrolled select: the editable selection is the DOM element itself.
// When the parent passes a new `currentTier` prop, the `key` on the
// <select> remounts it and re-applies `defaultValue` from the new prop,
// so the displayed tier always tracks the server-side value.
const selectRef = useRef<HTMLSelectElement>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSaveTier() {
const selectedTier = selectRef.current?.value ?? currentTier;
if (selectedTier === currentTier) return;
setSaving(true);
setError(null);
const result = await updateBrandPlanTier(brandId, selectedTier);
setSaving(false);
if (result.success) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
window.location.reload();
} else {
setError(result.error ?? "Failed to update plan");
}
}
return (
<div className="flex items-center gap-2">
<select aria-label="Select"
ref={selectRef}
key={currentTier}
defaultValue={currentTier}
className="rounded-xl border border-zinc-600 bg-zinc-900 px-3 sm:px-4 py-3 sm:py-2 text-sm outline-none focus:border-blue-500 min-h-[44px]"
>
{TIERS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<button type="button"
onClick={handleSaveTier}
disabled={saving}
className="rounded-xl bg-slate-900 px-4 sm:px-5 py-3 sm:py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50 active:scale-95 transition-all min-h-[44px]"
>
{saving ? "Saving..." : saved ? "Saved!" : "Save Tier"}
</button>
{error && <span className="text-xs text-red-400">{error}</span>}
</div>
);
}
@@ -1,653 +0,0 @@
"use client";
import { useState } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
// ── Integration types ───────────────────────────────────────────────────────────
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
required?: boolean;
};
type SyncOption = {
key: string;
label: string;
description?: string;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
syncOptions: SyncOption[];
};
type AvailableIntegration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
};
// ── Integration catalogue ───────────────────────────────────────────────────────
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "AI",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "bg-violet-950/50 ring-violet-800",
credentials: [
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
],
syncOptions: [
{ key: "campaign_writer", label: "Campaign Idea Generator", description: "AI-powered email campaign ideas" },
{ key: "product_writer", label: "Product Description Writer", description: "AI product copy and alt text" },
{ key: "report_explainer", label: "Report Explainer", description: "Plain-English report summaries" },
{ key: "pricing_advisor", label: "Pricing Advisor", description: "AI pricing recommendations" },
{ key: "demand_forecast", label: "Demand Forecasting", description: "Predict order volumes" },
{ key: "route_optimizer", label: "Route Optimizer", description: "AI stop sequence optimization" },
{ key: "customer_insights", label: "Customer Insights", description: "NL query for orders & customers" },
],
},
{
id: "resend",
name: "Resend",
icon: "Email",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "bg-amber-950/50 ring-amber-800",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
syncOptions: [
{ key: "transactional", label: "Transactional Email", description: "Order confirmations, pickup reminders" },
{ key: "marketing", label: "Marketing Campaigns", description: "Harvest Reach email campaigns" },
{ key: "stop_blast", label: "Stop Blast Messages", description: "Automated stop notification emails" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "Stripe",
description: "Process online payments for orders. Collect card details and handle refunds.",
accentColor: "bg-zinc-800/50 ring-zinc-700",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
],
syncOptions: [
{ key: "checkout", label: "Online Checkout", description: "Branded checkout for orders" },
{ key: "refunds", label: "Refund Processing", description: "Issue partial or full refunds" },
{ key: "customer_portal", label: "Customer Portal", description: "Invoice and payment management" },
],
},
{
id: "twilio",
name: "Twilio",
icon: "SMS",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "bg-blue-950/50 ring-blue-800",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
syncOptions: [
{ key: "sms_campaigns", label: "SMS Campaigns", description: "Harvest Reach text message campaigns" },
{ key: "alerts", label: "Order Alerts", description: "SMS pickup and shipping notifications" },
{ key: "two_way", label: "Two-Way Messaging", description: "Customer replies and responses" },
],
},
];
const AVAILABLE_INTEGRATIONS: AvailableIntegration[] = [
{ id: "shipbob", name: "ShipBob", icon: "3PL", description: "Fulfillment for e-commerce orders", accentColor: "bg-blue-950/50 ring-blue-800" },
{ id: "fedex", name: "FedEx", icon: "FedEx", description: "Live shipping rates and label printing", accentColor: "bg-amber-950/50 ring-amber-800" },
{ id: "quickbooks", name: "QuickBooks", icon: "QB", description: "Sync orders and invoices to QuickBooks Online", accentColor: "bg-green-950/50 ring-green-800" },
{ id: "hubspot", name: "HubSpot", icon: "CRM", description: "CRM sync for customer and order data", accentColor: "bg-orange-950/50 ring-orange-800" },
];
type Props = {
brandId: string;
brands: { id: string; name: string }[];
isPlatformAdmin: boolean;
paymentSettings?: {
provider?: string | null;
stripe_publishable_key?: string | null;
stripe_secret_key?: string | null;
square_access_token?: string | null;
square_location_id?: string | null;
square_sync_enabled?: boolean;
square_inventory_mode?: string;
} | null;
resendConfigured?: boolean;
};
const iconColors: Record<string, string> = {
AI: "bg-violet-900 text-violet-300",
Email: "bg-amber-900 text-amber-300",
Stripe: "bg-zinc-800 text-zinc-200",
SMS: "bg-blue-900 text-blue-300",
"3PL": "bg-blue-900 text-blue-300",
FedEx: "bg-amber-900 text-amber-300",
QB: "bg-green-900 text-green-300",
CRM: "bg-orange-900 text-orange-300",
};
// ── Individual integration card ────────────────────────────────────────────────
function IntegrationCard({
integration,
brandId,
initialPaymentSettings,
resendConfigured,
}: {
integration: Integration;
brandId: string;
initialPaymentSettings?: Props["paymentSettings"];
resendConfigured?: boolean;
}) {
const hasStripeCredentials = !!(integration.id === "stripe" && initialPaymentSettings?.stripe_publishable_key);
const hasResendCredentials = !!(integration.id === "resend" && resendConfigured);
const [enabled, setEnabled] = useState(hasStripeCredentials || hasResendCredentials);
const [env, setEnv] = useState("sandbox");
const [syncEnabled, setSyncEnabled] = useState<Record<string, boolean>>({});
const [credentials, setCredentials] = useState<Record<string, string>>(() => {
if (integration.id === "stripe" && initialPaymentSettings) {
const c: Record<string, string> = {};
if (initialPaymentSettings.stripe_publishable_key)
c["STRIPE_PUBLISHABLE_KEY"] = initialPaymentSettings.stripe_publishable_key;
if (initialPaymentSettings.stripe_secret_key)
c["STRIPE_SECRET_KEY"] = initialPaymentSettings.stripe_secret_key;
return c;
}
if (integration.id === "resend" && resendConfigured) {
const c: Record<string, string> = {};
if (process.env.NEXT_PUBLIC_RESEND_API_KEY)
c["RESEND_API_KEY"] = process.env.NEXT_PUBLIC_RESEND_API_KEY;
if (process.env.NEXT_PUBLIC_FROM_EMAIL)
c["RESEND_FROM_EMAIL"] = process.env.NEXT_PUBLIC_FROM_EMAIL;
if (process.env.NEXT_PUBLIC_FROM_NAME)
c["RESEND_FROM_NAME"] = process.env.NEXT_PUBLIC_FROM_NAME;
return c;
}
return {};
});
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
if (integration.id === "stripe") {
const secretKey = credentials["STRIPE_SECRET_KEY"]?.trim();
if (!secretKey) {
setTestResult({ ok: false, message: "No secret key entered" });
setTesting(false);
return;
}
if (!secretKey.startsWith("sk_live_") && !secretKey.startsWith("sk_test_")) {
setTestResult({ ok: false, message: "Invalid secret key format — must start with sk_live_ or sk_test_" });
setTesting(false);
return;
}
try {
const res = await fetch("https://api.stripe.com/v1/balance", {
method: "GET",
headers: {
Authorization: `Bearer ${secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (res.ok) {
const data = await res.json();
const currency = data.currency?.toUpperCase() ?? "USD";
const available = data.available?.amount ?? 0;
setTestResult({ ok: true, message: `Connected! Balance: ${currency} $${(available / 100).toFixed(2)}` });
} else {
const err = await res.json().catch(() => ({}));
setTestResult({ ok: false, message: err.error?.message ?? `HTTP ${res.status}` });
}
} catch (e: unknown) {
setTestResult({ ok: false, message: e instanceof Error ? e.message : "Network error" });
}
} else {
await new Promise((r) => setTimeout(r, 1200));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
if (hasKey) {
setTestResult({ ok: true, message: `Successfully connected to ${integration.name}` });
} else {
setTestResult({ ok: false, message: `No API key configured for ${integration.name}` });
}
}
setTesting(false);
}
async function handleSave() {
if (integration.id === "stripe") {
setSaving(true);
setError(null);
try {
const res = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!res.success) {
setError(res.error ?? "Failed to save Stripe settings");
} else {
setSaving(false);
return;
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to save");
}
setSaving(false);
return;
}
setSaving(true);
await new Promise((r) => setTimeout(r, 800));
setSaving(false);
}
function toggleSync(key: string) {
setSyncEnabled((prev) => ({ ...prev, [key]: !prev[key] }));
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
const iconColors: Record<string, string> = {
AI: "bg-violet-900 text-violet-300",
Email: "bg-amber-900 text-amber-300",
Stripe: "bg-zinc-800 text-zinc-200",
SMS: "bg-blue-900 text-blue-300",
"3PL": "bg-blue-900 text-blue-300",
FedEx: "bg-amber-900 text-amber-300",
QB: "bg-green-900 text-green-300",
CRM: "bg-orange-900 text-orange-300",
};
return (
<div className={`rounded-2xl border p-5 bg-zinc-900 shadow-black/20 ring-1 ${integration.accentColor.replace("50", "950/50")}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-zinc-800 text-zinc-300"}`}>{integration.icon}</span>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-zinc-100">{integration.name}</h3>
<span className={`text-xs font-medium rounded-full px-2 py-0.5 ${
enabled
? "bg-green-900/60 text-green-300 border border-green-700"
: "bg-zinc-800 text-zinc-500 border border-zinc-700"
}`}>
{enabled ? "Connected" : "Not Configured"}
</span>
</div>
<p className="text-xs text-stone-400 mt-0.5">{integration.description}</p>
</div>
</div>
<div className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
aria-label={`Enable ${integration.name}`}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-4 rtl:peer-checked:after:-translate-x-4 peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-zinc-900 after:border-zinc-600 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
</div>
</div>
{/* Sync options */}
{integration.syncOptions && integration.syncOptions.length > 0 && (
<div className="mb-5">
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Features</p>
<div className="space-y-2">
{integration.syncOptions.map((opt) => (
<label key={opt.key} className="flex items-start gap-2 cursor-pointer group">
<input
type="checkbox"
checked={syncEnabled[opt.key] ?? false}
onChange={() => toggleSync(opt.key)}
className="mt-0.5 rounded border-zinc-600 bg-zinc-800 text-violet-500 focus:ring-violet-500"
/>
<div className="flex-1">
<span className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">{opt.label}</span>
{opt.description && (
<span className="block text-xs text-zinc-500">{opt.description}</span>
)}
</div>
</label>
))}
</div>
</div>
)}
{/* Credentials */}
<div className="space-y-3 mb-5">
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p>
{integration.credentials.map((field) => {
// Only mask the field as password when explicitly marked as a secret
// (e.g. API keys, tokens). Plain identifiers (emails, phone numbers,
// IDs) stay readable so admins can verify what they entered.
const inputId = `integration-${integration.id}-${field.key}`;
const inputType = field.isSecret
? showSecrets[field.key]
? "text"
: "password"
: (field as { type?: string }).type ?? "text";
return (
<div key={field.key}>
<label htmlFor={inputId} className="block text-xs font-medium text-zinc-400 mb-1">
{field.label}
{field.required && <span className="text-red-400 ml-1" aria-hidden="true">*</span>}
</label>
<div className="relative">
<input aria-label="Input"
id={inputId}
type={inputType}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
aria-required={field.required ? "true" : undefined}
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
aria-label={showSecrets[field.key] ? `Hide ${field.label}` : `Show ${field.label}`}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Environment toggle */}
<div className="mb-5">
<p className="block text-xs font-medium text-zinc-400 mb-1">Environment</p>
<div className="flex gap-2">
{["sandbox", "production"].map((e) => (
<button type="button"
key={e}
onClick={() => setEnv(e)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
env === e
? "bg-zinc-100 text-zinc-900"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
}`}
>
{e.charAt(0).toUpperCase() + e.slice(1)}
</button>
))}
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-green-900/50 text-green-300 border border-green-700" : "bg-red-900/50 text-red-300 border border-red-700"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-900/50 text-red-300 border border-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button type="button"
onClick={handleTest}
disabled={testing || !enabled}
className="rounded-lg border border-zinc-600 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{testing ? "Testing..." : "Test Connection"}
</button>
<button type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-violet-600 px-4 py-2 text-xs font-bold text-white hover:bg-violet-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
);
}
// ── Custom integration type ────────────────────────────────────────────────────
type CustomIntegration = {
id: string;
name: string;
type: "ai_provider" | "payment_processor" | "other";
enabled: boolean;
credentials: { key: string; label: string; value: string; isSecret?: boolean }[];
syncOptions: string[];
testEndpoint?: string;
};
// ── Main client component ───────────────────────────────────────────────────────
export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, paymentSettings, resendConfigured }: Props) {
// selectedBrandId and currentPaymentSettings are derived from props.
// The parent IntegrationsClientPage owns these via its own local state
// (keyed to brandId) and re-renders this component with fresh values.
const selectedBrandId = brandId;
const currentPaymentSettings = paymentSettings;
const [search, setSearch] = useState("");
const [showAddModal, setShowAddModal] = useState(false);
const [addSearch, setAddSearch] = useState("");
const [customIntegrations, setCustomIntegrations] = useState<CustomIntegration[]>([]);
// Integrations shown in the grid — OpenAI card is hidden since it's now handled by AIProviderPanel
const gridIntegrations = INTEGRATIONS.filter((i) => i.id !== "openai");
const filtered = gridIntegrations.filter(
(i) =>
i.name.toLowerCase().includes(search.toLowerCase()) ||
i.description.toLowerCase().includes(search.toLowerCase())
);
const availableFiltered = AVAILABLE_INTEGRATIONS.filter(
(a) =>
!gridIntegrations.find((i) => i.id === a.id) &&
(a.name.toLowerCase().includes(addSearch.toLowerCase()) ||
a.description.toLowerCase().includes(addSearch.toLowerCase()))
);
function addIntegration(app: AvailableIntegration) {
setShowAddModal(false);
setAddSearch("");
}
function handleAddCustom(name: string, type: "ai_provider" | "payment_processor" | "other") {
if (!name.trim()) return;
const newInt: CustomIntegration = {
id: `custom_${Date.now()}`,
name: name.trim(),
type,
enabled: false,
credentials: [],
syncOptions: [],
};
setCustomIntegrations((prev) => [...prev, newInt]);
}
return (
<div>
{/* Top bar */}
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-4">
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-zinc-800 border border-zinc-700">
<svg className="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-zinc-100">Integrations</h1>
<p className="text-xs text-zinc-500">Connect payment processors, email providers, and other services.</p>
</div>
</div>
<button type="button"
onClick={() => setShowAddModal(true)}
className="rounded-xl bg-violet-600 px-4 py-2.5 text-xs font-bold text-white hover:bg-violet-700 shrink-0"
aria-label="+ Add Integration">
+ Add Integration
</button>
</div>
</div>
</div>
<div className="mx-auto max-w-6xl px-6 py-8">
{/* Brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div className="mb-6 flex items-center gap-3">
<label htmlFor="fld-1-brand" className="text-sm font-medium text-zinc-400">Brand</label>
<select id="fld-1-brand" aria-label="Select"
value={selectedBrandId}
onChange={() => {
// selectedBrandId is derived from the `brandId` prop.
// The parent component owns the selection state and re-renders
// this client with the new brandId when needed.
}}
className="rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 outline-none focus:border-violet-500"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{/* Search */}
<div className="mb-6">
<input aria-label="Search Integrations..."
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search integrations..."
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-violet-500"
/>
</div>
{/* AI Provider panel */}
<div className="mb-8">
<AIProviderPanel brandId={selectedBrandId} />
</div>
{/* Grid */}
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((int) => (
<IntegrationCard
key={int.id}
integration={int}
brandId={selectedBrandId}
initialPaymentSettings={currentPaymentSettings}
resendConfigured={resendConfigured}
/>
))}
</div>
{/* Custom integrations */}
{customIntegrations.length > 0 && (
<div className="mt-8">
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Custom</h2>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{customIntegrations.map((ci) => (
<div key={ci.id} className="rounded-2xl border border-zinc-700 bg-zinc-900 p-5">
<p className="text-sm font-semibold text-zinc-200">{ci.name}</p>
<p className="text-xs text-zinc-500 mt-1">{ci.type}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Add integration modal */}
{showAddModal && (
<div
role="dialog"
aria-modal="true"
aria-label="Add Integration"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setShowAddModal(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setShowAddModal(false);
}}
>
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 border border-zinc-700 p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-zinc-100">Add Integration</h2>
<button type="button" onClick={() => setShowAddModal(false)} className="text-zinc-500 hover:text-zinc-300" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<input aria-label="Search..."
type="text"
value={addSearch}
onChange={(e) => setAddSearch(e.target.value)}
placeholder="Search..."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-violet-500 mb-4"
/>
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableFiltered.map((app) => (
<button type="button"
key={app.id}
onClick={() => addIntegration(app)}
className="w-full flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-800 p-4 text-left hover:bg-zinc-700"
>
<span className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[app.icon] ?? "bg-zinc-700 text-zinc-300"}`}>{app.icon}</span>
<div>
<p className="text-sm font-medium text-zinc-200">{app.name}</p>
<p className="text-xs text-zinc-500">{app.description}</p>
</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,129 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function ChangePasswordForm({ userId }: { userId: string }) {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, password }),
});
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? data.error ?? "Failed to update password.");
setLoading(false);
return;
}
router.push("/admin");
router.refresh();
} catch {
setError("An unexpected error occurred. Please try again.");
setLoading(false);
}
}
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
<svg
className="h-6 w-6 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
<p className="mt-1 text-sm text-zinc-500">
You must set a new password before continuing.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
{error}
</div>
)}
<div>
<label htmlFor="fld-1-new-password" className="block text-sm font-medium text-zinc-400 mb-1">New Password</label>
<input id="fld-1-new-password" aria-label=". 8 Characters"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Min. 8 characters"
/>
</div>
<div>
<label htmlFor="fld-2-confirm-password" className="block text-sm font-medium text-zinc-400 mb-1">Confirm Password</label>
<input id="fld-2-confirm-password" aria-label="Repeat Password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Repeat password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
>
{loading ? "Updating..." : "Update Password"}
</button>
</form>
<div className="mt-4 border-t border-zinc-800 pt-4">
<Link
href="/logout"
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Sign out instead
</Link>
</div>
</div>
</div>
</main>
);
}
-218
View File
@@ -1,218 +0,0 @@
/**
* @deprecated Use AdminSidebar instead. AdminHeader is deprecated and will be removed.
* The AdminSidebar provides the complete navigation system for admin pages.
* It includes an expandable Settings sub-menu with all settings pages.
*/
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { signOutAction } from "@/actions/auth-actions";
async function handleLogout() {
await signOutAction();
}
type AdminHeaderProps = {
userRole?: string | null;
canManageUsers?: boolean;
routeTraceEnabled?: boolean;
};
type NavLink = {
href: string;
label: string;
external?: boolean;
};
type SettingsItem = {
href: string;
label: string;
description: string;
};
const SETTINGS_ITEMS: SettingsItem[] = [
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence, recovered carts" },
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
];
const BASE_NAV_LINKS: NavLink[] = [
{ href: "/admin/orders", label: "Orders" },
{ href: "/admin/stops", label: "Stops & Routes" },
{ href: "/admin/products", label: "Products" },
{ href: "/admin/time-tracking", label: "Time Tracking" },
{ href: "/admin/communications", label: "Harvest Reach" },
];
const EMPLOYEE_NAV_LINKS: NavLink[] = [
{ href: "/admin/wholesale", label: "Wholesale" },
{ href: "/admin/pickup", label: "Pickup" },
{ href: "/wholesale/employee", label: "Pickup Portal", external: true },
];
export default function AdminHeader({ userRole, canManageUsers, routeTraceEnabled }: AdminHeaderProps) {
const router = useRouter();
const pathname = usePathname();
const isStoreEmployee = userRole === "store_employee";
const moduleLinks: NavLink[] = routeTraceEnabled
? [{ href: "/admin/route-trace", label: "Route Trace" }]
: [];
const fullNavLinks = BASE_NAV_LINKS.concat(moduleLinks);
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") {
return pathname === "/admin/settings" || pathname.startsWith("/admin/settings/");
}
return pathname.startsWith(href);
};
return (
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
<div className="flex items-center h-16">
{/* Logo mark */}
<Link
href={homeHref}
className="flex items-center gap-3 mr-8 text-white hover:text-emerald-400 transition-colors"
>
<div className="relative">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/30">
<svg className="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l.707-.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
<span className="text-sm font-semibold tracking-tight">{homeLabel}</span>
</Link>
<nav className="flex h-full items-center gap-1">
{navLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200",
active
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
{link.label}
</Link>
);
})}
{/* Settings dropdown — key={pathname} remounts on route change to auto-close */}
<SettingsDropdown key={pathname} pathname={pathname} />
</nav>
</div>
<div className="flex items-center gap-6">
{roleLabel && (
<div className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/5">
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
</div>
)}
<button type="button"
onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
</header>
);
}
function SettingsDropdown({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<div className="relative ml-2" ref={ref}>
<button type="button"
onClick={() => setOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
);
}
-349
View File
@@ -1,349 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { publishStop, deleteStop } from "@/actions/stops";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brand_id: string;
status?: string;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: Stop[];
brandId: string;
};
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
} catch {
return dateStr;
}
}
function formatTime(timeStr: string) {
if (!timeStr) return "—";
try {
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
} catch {
return timeStr;
}
}
export default function AdminStopsPanel({ stops, brandId }: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [page, setPage] = useState(0);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const PAGE_SIZE = 50;
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
await publishStop(stopId, brandId);
setPublishingId(null);
router.refresh();
}
async function handleDelete(stopId: string) {
const data = await deleteStop(stopId, brandId);
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
router.refresh();
}
}
const activeCount = stops.filter((s) => s.active && s.status !== "draft").length;
const draftCount = stops.filter((s) => s.status === "draft").length;
return (
<div className="min-h-screen bg-stone-50">
{/* Header */}
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-7xl px-6 py-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-stone-950 tracking-tight">Tour Stops</h1>
<div className="mt-2 flex items-center gap-4">
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{activeCount}</span> active
</span>
{draftCount > 0 && (
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{draftCount}</span> drafts
</span>
)}
</div>
</div>
<div className="flex gap-3">
<button type="button"
onClick={() => setShowImport(true)}
className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Upload Schedule">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload Schedule
</button>
<button type="button"
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
aria-label="Add Stop">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add Stop
</button>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-6">
{/* Filters */}
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm mb-5">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-64">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input aria-label="Search City Or Location..."
type="search"
placeholder="Search city or location..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20"
/>
</div>
{/* Status tabs */}
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{(["all", "active", "draft", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
statusFilter === f
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
}`}
>
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Count */}
<span className="ml-auto text-sm text-stone-500">{filtered.length} stops</span>
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
{filtered.length === 0 ? (
<div className="py-16 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="text-lg font-medium text-stone-600">No stops found</p>
<p className="mt-1 text-sm text-stone-400">Create a stop or adjust your filters</p>
</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">Brand</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Status</th>
<th className="px-5 py-4" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{paginatedStops.map((stop) => (
<tr key={stop.id} className="hover:bg-stone-50 transition-colors">
<td className="px-5 py-4">
<Link href={`/admin/stops/${stop.id}`} className="font-medium text-stone-900 hover:text-emerald-600 transition-colors">
{stop.city}, {stop.state}
</Link>
</td>
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatDate(stop.date)}</span>
</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatTime(stop.time)}</span>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<span className="text-stone-600">
{Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands?.name}
</span>
</td>
<td className="px-5 py-4">
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button type="button"
onClick={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
className="rounded-lg px-2 py-1.5 text-[var(--admin-text-muted)] hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{openMenu === stop.id && (
<>
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-lg">
{stop.status === "draft" && (
<button type="button"
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
)}
<a href={`/admin/stops/new?duplicate=${stop.id}`} className="block px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50">
Duplicate
</a>
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50"
aria-label="Delete">
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<button type="button" aria-label="Cancel delete confirmation" className="fixed inset-0 z-30 cursor-default bg-transparent border-0 p-0" onClick={() => setConfirmDelete(null)} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg p-4">
<p className="text-sm font-semibold text-stone-900">Delete &quot;{stop.city}, {stop.state}&quot;?</p>
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
<div className="mt-4 flex gap-2">
<button type="button"
onClick={() => setConfirmDelete(null)}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(stop.id)}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--admin-danger-hover)]"
aria-label="Delete">
Delete
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-5 flex items-center justify-between">
<p className="text-sm text-stone-500">
Showing {page * PAGE_SIZE + 1} to {Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length}
</p>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Previous">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Next">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</main>
{/* Modals */}
<ScheduleImportModal brandId={brandId} onClose={() => setShowImport(false)} onComplete={() => router.refresh()} />
<AddStopModal isOpen={showAdd} onClose={() => setShowAdd(false)} brandId={brandId} onSuccess={() => router.refresh()} />
</div>
);
}
@@ -1,240 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import AdvancedIntegrations from "@/components/admin/AdvancedIntegrations";
import AdvancedAIPanel from "@/components/admin/AdvancedAIPanel";
import AdvancedSquareSync from "@/components/admin/AdvancedSquareSync";
import AdvancedShipping from "@/components/admin/AdvancedShipping";
import AdvancedPayments from "@/components/admin/AdvancedPayments";
type Tab = "integrations" | "ai" | "square" | "shipping" | "webhooks" | "payments";
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "integrations", label: "Integrations", icon: "plug" },
{ id: "ai", label: "AI Tools", icon: "sparkles" },
{ id: "square", label: "Square Sync", icon: "square" },
{ id: "shipping", label: "Shipping", icon: "truck" },
{ id: "webhooks", label: "Webhooks", icon: "webhook" },
{ id: "payments", label: "Payments", icon: "credit-card" },
];
// Icon components
const Icons = {
plug: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>
),
sparkles: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"/>
</svg>
),
square: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 12h6M12 9v6"/>
</svg>
),
truck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
</svg>
),
webhook: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M8.25 8.25l-.75.75M14.25 14.25l-.75.75M8.25 14.25l.75-.75M14.25 8.25l.75.75"/>
</svg>
),
"credit-card": (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<path d="M2 10h20"/>
</svg>
),
};
type Props = {
brandId: string;
brands: { id: string; name: string }[];
stripeConnect?: {
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
} | null;
};
export default function AdvancedSettingsClient({ brandId, brands, stripeConnect }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("integrations");
// Handle Stripe Connect callback params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("stripe_connected") === "true") {
// Successfully connected - could show a toast or refresh
window.history.replaceState({}, "", window.location.pathname);
} else if (params.get("stripe_refresh") === "true") {
// Link expired, need to refresh
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
return (
<div>
{/* Tab navigation */}
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
{TABS.map((tab) => (
<button type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
activeTab === tab.id
? "bg-[var(--admin-accent)] text-white"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
}`}
>
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
{tab.icon === "square" && Icons.square("h-4 w-4")}
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
)}
</button>
))}
</nav>
{/* Content */}
<div className="py-4 sm:py-6">
{activeTab === "integrations" && (
<AdvancedIntegrations brandId={brandId} brands={brands} />
)}
{activeTab === "ai" && (
<AdvancedAIPanel brandId={brandId} />
)}
{activeTab === "square" && (
<AdvancedSquareSync brandId={brandId} />
)}
{activeTab === "shipping" && (
<AdvancedShipping brandId={brandId} />
)}
{activeTab === "webhooks" && (
<div className="space-y-6">
{/* Page Header */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--admin-bg)]">
{Icons.webhook("h-6 w-6 text-[var(--admin-text-muted)]")}
</div>
<div className="flex-1">
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Configure webhook endpoints to receive real-time notifications for order events, payments, and inventory updates.
</p>
</div>
</div>
</div>
{/* Coming Soon Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="bg-[var(--admin-bg)]/50 px-6 py-4 border-b border-[var(--admin-border)]">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Available Webhook Events</h3>
</div>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
<svg className="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Coming Soon</p>
<p className="text-xs text-[var(--admin-text-muted)]">Webhook configuration will be available shortly</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-6">
{[
{ icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", label: "Order Status Changes", desc: "Track order lifecycle events" },
{ icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: "Payment Confirmations", desc: "Get notified on successful payments" },
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
].map((event, i) => (
<div key={`${event.label}-${event.desc}`} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-[var(--admin-border)]">
<svg className="h-4 w-4 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={event.icon}/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{event.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{event.desc}</p>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span className="text-sm text-[var(--admin-text-secondary)]">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
</div>
</div>
<div className="mt-6 flex items-center justify-between pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Need webhooks now?{" "}
<button type="button" className="text-green-600 hover:text-green-700 font-medium">
Contact support
</button>
</p>
<button type="button" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Notify Me
</button>
</div>
</div>
</div>
{/* Technical Info Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-3">Webhook Payload Format</h4>
<div className="bg-[var(--admin-text-primary)] rounded-lg p-4 overflow-x-auto">
<pre className="text-xs text-[var(--admin-bg)] font-mono leading-relaxed">
{`{
"event": "order.status_changed",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"order_id": "...",
"status": "delivered"
}
}`}
</pre>
</div>
</div>
</div>
)}
{activeTab === "payments" && (
<AdvancedPayments brandId={brandId} initialStatus={stripeConnect ?? null} />
)}
</div>
</div>
);
}
-189
View File
@@ -1,189 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
import { toggleBrandFeature } from "@/actions/settings/features";
import GlassModal from "./GlassModal";
type Props = {
brandId: string;
initialEnabledFeatures: Record<string, boolean>;
};
export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: Props) {
const router = useRouter();
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
const [toggling, setToggling] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showEnableModal, setShowEnableModal] = useState<BrandFeatureKey | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleToggle = async (key: BrandFeatureKey, enable: boolean) => {
setToggling(key);
setShowEnableModal(null);
setEnabledFeatures((prev) => ({ ...prev, [key]: enable }));
const result = await toggleBrandFeature(brandId, key, enable);
if (result.success) {
showToast(enable ? `${ADDON_CATALOG[key].name} enabled` : `${ADDON_CATALOG[key].name} disabled`);
router.refresh();
} else {
setEnabledFeatures((prev) => ({ ...prev, [key]: !enable }));
showToast(`Error: ${result.error}`, 'error');
}
setToggling(null);
};
const keys = Object.keys(ADDON_CATALOG) as BrandFeatureKey[];
return (
<>
{toast && (
<div
className="fixed bottom-6 right-6 z-50 rounded-xl px-5 py-3 text-sm font-medium shadow-lg backdrop-blur-sm"
style={{
background: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(15, 118, 110, 0.95)',
color: 'white',
}}
>
{toast.message}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{keys.map((key) => {
const addon = ADDON_CATALOG[key];
const enabled = !!enabledFeatures[key];
const busy = toggling === key;
return (
<div
key={key}
className="rounded-2xl p-6 transition-all"
style={{
background: 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(20px)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.3)' : '1px solid rgba(0, 0, 0, 0.06)',
boxShadow: enabled
? '0 8px 24px rgba(16, 185, 129, 0.1), inset 0 1px 0 rgba(255,255,255,0.8)'
: '0 4px 12px rgba(0, 0, 0, 0.04)',
}}
>
<div className="flex items-start gap-4">
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
style={{
background: enabled
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)'
: 'rgba(0, 0, 0, 0.04)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
<span className="text-2xl">{addon.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-stone-950">{addon.name}</h3>
<span
className="text-xs font-medium rounded-full px-2.5 py-0.5"
style={{
background: enabled ? 'rgba(16, 185, 129, 0.15)' : 'rgba(0, 0, 0, 0.04)',
color: enabled ? '#047857' : 'rgba(0, 0, 0, 0.4)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
{enabled ? "Active" : "Inactive"}
</span>
</div>
<p className="text-sm text-stone-500 leading-relaxed">
{addon.description}
</p>
{addon.addOnPrice && (
<p className="mt-2 text-xs font-medium" style={{ color: '#b45309' }}>
{addon.addOnPrice}/month
</p>
)}
</div>
</div>
<div className="mt-5 flex items-center justify-between">
<button type="button"
onClick={() => handleToggle(key, !enabled)}
disabled={busy}
className="rounded-xl px-5 py-2.5 text-sm font-semibold transition-all"
style={{
background: enabled
? 'rgba(239, 68, 68, 0.1)'
: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
color: enabled ? '#dc2626' : 'white',
border: enabled ? '1px solid rgba(239, 68, 68, 0.2)' : 'none',
boxShadow: enabled ? 'none' : '0 2px 8px rgba(16, 185, 129, 0.25)',
opacity: busy ? 0.5 : 1,
}}
>
{busy ? "..." : enabled ? "Disable" : "Enable"}
</button>
{enabled && addon.adminRoute && (
<a
href={addon.adminRoute}
className="rounded-xl px-4 py-2 text-sm font-medium transition-all"
style={{
background: 'rgba(0, 0, 0, 0.02)',
color: 'rgba(0, 0, 0, 0.6)',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
Open
</a>
)}
</div>
</div>
);
})}
</div>
{/* Enable confirmation modal */}
{showEnableModal && (
<GlassModal
title={`Enable ${ADDON_CATALOG[showEnableModal].name}?`}
subtitle={`This will activate the add-on feature.`}
onClose={() => setShowEnableModal(null)}
>
<div className="space-y-4">
<p className="text-sm text-stone-500">
{ADDON_CATALOG[showEnableModal].description}
</p>
{ADDON_CATALOG[showEnableModal].addOnPrice && (
<p className="text-sm font-medium" style={{ color: '#b45309' }}>
Price: {ADDON_CATALOG[showEnableModal].addOnPrice}/month
</p>
)}
<div className="flex justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button type="button"
onClick={() => setShowEnableModal(null)}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 transition-all"
>
Cancel
</button>
<button type="button"
onClick={() => handleToggle(showEnableModal, true)}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.25)',
}}
>
Enable
</button>
</div>
</div>
</GlassModal>
)}
</>
);
}
-24
View File
@@ -1,24 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useCart } from "@/context/CartContext";
type Props = { userId: string };
export default function CartHydration({ userId }: Props) {
const { loadServerCart } = useCart();
// Hold the latest loadServerCart via ref so the effect below can call the
// freshest closure without depending on it (which would re-fire on every
// render of CartProvider and trigger an infinite hydration loop).
const loadServerCartRef = useRef(loadServerCart);
useEffect(() => {
loadServerCartRef.current = loadServerCart;
});
useEffect(() => {
if (!userId) return;
loadServerCartRef.current(userId).catch(() => {});
}, [userId]);
return null;
}
@@ -1,276 +0,0 @@
"use client";
// Loading skeleton components for Communications page
function SkeletonBlock({ className = "" }: { className?: string }) {
return (
<div className={`animate-pulse bg-stone-200 rounded ${className}`} />
);
}
function SkeletonCard() {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-3 w-24" />
</div>
</div>
<SkeletonBlock className="h-9 w-28 rounded-lg" />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-stone-50 rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
<SkeletonBlock className="h-3 w-16 mb-2" />
<SkeletonBlock className="h-6 w-20" />
</div>
))}
</div>
<SkeletonBlock className="h-64 w-full rounded-xl" />
</div>
);
}
function SkeletonTableRow() {
return (
<tr className="border-b border-[var(--admin-border)]">
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-32" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-20" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
<td className="px-4 sm:px-6 py-3.5">
<SkeletonBlock className="h-4 w-16" />
</td>
</tr>
);
}
function SkeletonTable({ rows = 5 }: { rows?: number }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
<tr>
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-16" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-16" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
<SkeletonBlock className="h-3 w-12" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
</div>
);
}
function SkeletonComposer() {
return (
<div className="flex flex-col gap-6">
{/* Step indicator skeleton */}
<div className="flex items-center gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center">
<SkeletonBlock className={`h-10 rounded-full ${i === 1 ? "w-24" : "w-20"}`} />
{i < 4 && <SkeletonBlock className="h-0.5 w-6 mx-1" />}
</div>
))}
</div>
{/* Main card skeleton */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="space-y-4">
<SkeletonBlock className="h-6 w-40" />
<SkeletonBlock className="h-4 w-64" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-6">
{[1, 2, 3, 4].map((i) => (
<SkeletonBlock key={i} className="h-28 rounded-xl" />
))}
</div>
</div>
</div>
{/* Recent campaigns skeleton */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-6 py-4 border-b border-[var(--admin-border)]">
<SkeletonBlock className="h-5 w-36" />
</div>
<SkeletonTable rows={3} />
</div>
</div>
);
}
function SkeletonSegmentBuilder() {
return (
<div className="p-4 sm:p-6 space-y-4">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-32" />
<SkeletonBlock className="h-4 w-48" />
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4">
{/* Sidebar skeleton */}
<div className="lg:w-72 flex-shrink-0">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="h-8 w-8 rounded-lg" />
</div>
<SkeletonBlock className="h-10 w-full" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-12 w-full rounded-xl" />
))}
</div>
</div>
</div>
{/* Builder + Preview skeleton */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-20" />
<SkeletonBlock className="h-8 w-16 rounded-lg" />
</div>
<SkeletonBlock className="h-24 w-full rounded-xl" />
<div className="flex gap-2 flex-wrap">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-7 w-24 rounded-full" />
))}
</div>
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
<div className="flex items-center justify-between">
<SkeletonBlock className="h-4 w-32" />
<SkeletonBlock className="h-6 w-16 rounded-full" />
</div>
<SkeletonBlock className="h-48 w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
}
function SkeletonContacts() {
return (
<div className="space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-24" />
<SkeletonBlock className="h-4 w-32" />
</div>
</div>
<div className="flex gap-2">
<SkeletonBlock className="h-9 w-24 rounded-lg" />
<SkeletonBlock className="h-9 w-32 rounded-lg" />
</div>
</div>
<SkeletonBlock className="h-10 w-full mb-4" />
<SkeletonTable rows={5} />
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<SkeletonBlock className="h-5 w-28 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<SkeletonBlock key={i} className="h-24 rounded-xl" />
))}
</div>
</div>
</div>
);
}
function SkeletonLogs() {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<SkeletonBlock className="h-10 w-10 rounded-xl" />
<div className="space-y-2">
<SkeletonBlock className="h-5 w-20" />
<SkeletonBlock className="h-4 w-32" />
</div>
</div>
<div className="flex gap-2">
<SkeletonBlock className="h-9 w-28 rounded-lg" />
<SkeletonBlock className="h-9 w-20 rounded-lg" />
</div>
</div>
<SkeletonBlock className="h-10 w-full mb-4" />
<SkeletonTable rows={6} />
</div>
);
}
// Main loading component that shows skeleton based on current tab
export default function CommunicationsLoading({
activeTab = "campaigns"
}: {
activeTab?: string
}) {
const currentTab = activeTab;
switch (currentTab) {
case "campaigns":
return <SkeletonCard />;
case "templates":
return <SkeletonCard />;
case "contacts":
return <SkeletonContacts />;
case "segments":
return <SkeletonSegmentBuilder />;
case "logs":
return <SkeletonLogs />;
case "analytics":
return <SkeletonCard />;
default:
return <SkeletonCard />;
}
}
// Named exports for specific loading states
export {
SkeletonCard,
SkeletonComposer,
SkeletonSegmentBuilder,
SkeletonContacts,
SkeletonLogs,
SkeletonTable,
SkeletonBlock,
};
-120
View File
@@ -1,120 +0,0 @@
"use client";
const TABS = [
{ id: "campaigns", label: "Campaigns" },
{ id: "compose", label: "Compose" },
{ id: "segments", label: "Segments" },
{ id: "analytics", label: "Analytics" },
{ id: "templates", label: "Templates" },
{ id: "contacts", label: "Contacts" },
{ id: "logs", label: "Logs" },
{ id: "settings", label: "Settings" },
];
// Icon components
const Icons = {
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
),
send: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m22 2-11 11"/>
<path d="M22 2 15 22l-4-9-9-4 20-7z"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
<line x1="10" x2="8" y1="9" y2="9"/>
</svg>
),
userCheck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<polyline points="16 11 18 13 22 9"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6"/>
<line x1="8" x2="21" y1="12" y2="12"/>
<line x1="8" x2="21" y1="18" y2="18"/>
<line x1="3" x2="3.01" y1="6" y2="6"/>
<line x1="3" x2="3.01" y1="12" y2="12"/>
<line x1="3" x2="3.01" y1="18" y2="18"/>
</svg>
),
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
filter: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
),
};
export default function CommunicationsNav({
activeTab,
}: {
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "segments" | "analytics" | "compose" | "settings";
}) {
return (
<nav className="grid grid-cols-4 sm:grid-cols-7 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={`/admin/communications/${tab.id}`}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-3 py-2.5 text-[10px] sm:text-xs font-semibold transition-colors ${
isActive
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.id === "campaigns" && Icons.send("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "compose" && Icons.mail("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "segments" && Icons.users("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "analytics" && Icons.chart("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "templates" && Icons.fileText("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "contacts" && Icons.userCheck("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "logs" && Icons.list("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "settings" && Icons.settings("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>{tab.label}</span>
{isActive && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-4 sm:w-8 bg-white rounded-full" />
)}
</a>
);
})}
</nav>
);
}
export { TABS, Icons };
-61
View File
@@ -1,61 +0,0 @@
"use client";
import Link from "next/link";
import { useState, useCallback } from "react";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
interface DashboardHeaderProps {
brandId: string | null;
brandName: string;
planTier: string;
}
export default function DashboardHeader({ brandId, brandName, planTier }: DashboardHeaderProps) {
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const openUpgrade = useCallback(() => setIsUpgradeOpen(true), []);
const closeUpgrade = useCallback(() => setIsUpgradeOpen(false), []);
return (
<>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
</div>
</div>
<div className="flex items-center gap-4">
<Link href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
Billing
</Link>
{planTier === "starter" && brandId && (
<button type="button"
onClick={openUpgrade}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
)}
</div>
</div>
{planTier === "starter" && brandId && (
<UpgradePlanModal
isOpen={isUpgradeOpen}
onClose={closeUpgrade}
brandId={brandId}
currentTier={planTier}
/>
)}
</>
);
}
@@ -1,36 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
interface DashboardUpgradeButtonProps {
brandId: string | null;
currentTier: string;
}
export default function DashboardUpgradeButton({ brandId, currentTier }: DashboardUpgradeButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);
if (!brandId) return null;
return (
<>
<button type="button"
onClick={openModal}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
<UpgradePlanModal
isOpen={isOpen}
onClose={closeModal}
brandId={brandId}
currentTier={currentTier}
/>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
title: string;
titleIcon?: React.ReactNode;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
variant?: "default" | "elegant";
};
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
// showModal() opens it in modal mode; we listen to "cancel" so the
// parent React state stays in sync with native close behavior.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
const isElegant = variant === "elegant";
return (
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
</dialog>
);
}
@@ -1,82 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{
href: "/admin/harvest-reach/segments",
label: "Segments",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
},
{
href: "/admin/harvest-reach/campaigns",
label: "Campaigns",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
),
},
{
href: "/admin/harvest-reach/analytics",
label: "Analytics",
icon: (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
},
];
export default function HarvestReachNav() {
const pathname = usePathname();
return (
<div className="flex flex-col">
{/* Brand header */}
<div className="flex items-center gap-3 pb-4 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-stone-900">Harvest Reach</h1>
<p className="text-xs text-stone-500">Email marketing & campaigns</p>
</div>
</div>
{/* Navigation tabs */}
<nav className="flex gap-1 p-1 bg-stone-100 rounded-xl">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg transition-all ${
active
? "bg-white text-emerald-600 shadow-sm"
: "text-stone-500 hover:text-stone-700 hover:bg-white/50"
}`}
>
{tab.icon}
<span>{tab.label}</span>
</Link>
);
})}
</nav>
</div>
);
}
-336
View File
@@ -1,336 +0,0 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
};
type SyncOption = {
key: string;
label: string;
description?: string;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
syncOptions: SyncOption[];
};
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "AI",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "border-violet-200",
credentials: [
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
],
syncOptions: [
{ key: "campaign_writer", label: "Campaign Idea Generator" },
{ key: "product_writer", label: "Product Description Writer" },
{ key: "report_explainer", label: "Report Explainer" },
{ key: "pricing_advisor", label: "Pricing Advisor" },
],
},
{
id: "resend",
name: "Resend",
icon: "Email",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
syncOptions: [
{ key: "transactional", label: "Transactional Email" },
{ key: "marketing", label: "Marketing Campaigns" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "Stripe",
description: "Process online payments for orders.",
accentColor: "border-stone-300",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
],
syncOptions: [
{ key: "checkout", label: "Online Checkout" },
{ key: "refunds", label: "Refund Processing" },
],
},
{
id: "twilio",
name: "Twilio",
icon: "SMS",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
syncOptions: [
{ key: "sms_campaigns", label: "SMS Campaigns" },
{ key: "alerts", label: "Order Alerts" },
],
},
];
const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
(i) => i.id !== "openai"
);
const iconColors: Record<string, string> = {
AI: "bg-violet-100 text-violet-700",
Email: "bg-amber-100 text-amber-700",
Stripe: "bg-stone-100 text-stone-700",
SMS: "bg-blue-100 text-blue-700",
};
function IntegrationCard({
integration,
initialCredentials,
brandId,
}: {
integration: Integration;
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTest() {
setTestResult(null);
setError(null);
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div className={`rounded-xl border p-5 bg-white shadow-sm ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-stone-100 text-stone-700"}`}>{integration.icon}</span>
<div>
<h3 className="text-base font-semibold text-stone-900">{integration.name}</h3>
<p className="text-xs text-stone-500 mt-0.5">{integration.description}</p>
</div>
</div>
</div>
<div className="space-y-3 mb-4">
{integration.credentials.map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
<div className="relative">
<input aria-label="Input"
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm pr-16 text-stone-900 placeholder:text-stone-400 outline-none focus:border-emerald-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
className="text-xs text-stone-400 hover:text-stone-600 px-1"
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</div>
</div>
))}
</div>
{testResult && (
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{error && (
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
)}
<div className="flex gap-2">
<button type="button" onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button type="button" onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
);
}
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function IntegrationsInner({ brandId, brands }: Props) {
// There is no in-component brand picker, so derive the selected brand from
// the current prop instead of copying it into useState.
const selectedBrandId = brandId;
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
return (
<div>
{/* AI Provider — separate panel */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-stone-800">AI Provider</h3>
<Link href="/admin/settings/ai" className="text-xs text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Configure AI </Link>
</div>
<AIProviderPanel brandId={selectedBrandId} />
</div>
{/* Payment & email integrations grid */}
<div className="space-y-3 mb-4">
{INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={selectedBrandId}
/>
))}
</div>
</div>
);
}
-387
View File
@@ -1,387 +0,0 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useTransition, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { deleteLocation } from "@/actions/locations";
import AddLocationModal from "@/components/admin/AddLocationModal";
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
export type AdminLocation = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
slug: string | null;
stop_count: number;
};
type Props = {
locations: AdminLocation[];
brandId: string;
};
type StatusFilter = "all" | "active" | "inactive";
export default function LocationsTab({ locations, brandId }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<LocationForEdit | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const PAGE_SIZE = 50;
useEffect(() => {
setIsLoading(true);
const t = setTimeout(() => setIsLoading(false), 250);
return () => clearTimeout(t);
}, [page, statusFilter, search]);
const counts = useMemo(
() => ({
all: locations.length,
active: locations.filter((l) => l.active).length,
inactive: locations.filter((l) => !l.active).length,
}),
[locations]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return locations.filter((l) => {
const matchesSearch =
!q ||
l.name.toLowerCase().includes(q) ||
(l.address ?? "").toLowerCase().includes(q) ||
(l.city ?? "").toLowerCase().includes(q) ||
(l.contact_name ?? "").toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && l.active) ||
(statusFilter === "inactive" && !l.active);
return matchesSearch && matchesStatus;
});
}, [locations, search, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
];
function handleAdded() {
setShowAdd(false);
showSuccess("Venue created");
startTransition(() => router.refresh());
}
function handleEdited() {
setEditing(null);
showSuccess("Venue updated");
startTransition(() => router.refresh());
}
async function handleDelete(loc: AdminLocation) {
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
setPendingDeleteId(loc.id);
setDeleteError(null);
const res = await deleteLocation(loc.id, brandId);
setPendingDeleteId(null);
if (res.success) {
showSuccess("Venue deleted");
startTransition(() => router.refresh());
} else {
setDeleteError(res.error ?? "Delete failed");
showError("Delete failed", res.error ?? "Unknown error");
}
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search venues by name, city, or contact…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(value as StatusFilter);
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<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="M12 4v16m8-8H4" />
</svg>
}
>
Add Venue
</AdminButton>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Address</th>
<th className="px-5 py-4 font-semibold">Contact</th>
<th className="px-5 py-4 font-semibold text-center">Stops</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center">
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-sm">
{search || statusFilter !== "all"
? "No venues match your filters."
: "No venues yet. Add your first venue to get started."}
</p>
</div>
</td>
</tr>
) : (
paginated.map((loc) => (
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
{(loc.city || loc.state) && (
<div className="text-xs text-[var(--admin-text-muted)]">
{[loc.city, loc.state].filter(Boolean).join(", ")}
</div>
)}
</td>
<td className="px-5 py-4">
{loc.address ? (
<div>
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
{loc.zip && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4">
{loc.contact_name || loc.phone || loc.contact_email ? (
<div className="space-y-0.5">
{loc.contact_name && (
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
)}
{loc.phone && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
)}
{loc.contact_email && (
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
{loc.contact_email}
</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4 text-center">
{loc.stop_count > 0 ? (
<span
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
style={{
background: "rgba(16, 185, 129, 0.12)",
color: "#047857",
}}
>
{loc.stop_count}
</span>
) : (
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
)}
</td>
<td className="px-5 py-4">
{loc.active ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
Active
</span>
) : (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
Inactive
</span>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button type="button"
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
aria-label="Edit venue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button type="button"
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
title="Delete venue"
aria-label="Delete venue">
{pendingDeleteId === loc.id ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
</svg>
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{showAdd && (
<AddLocationModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAdded}
/>
)}
{editing && (
<EditLocationModal
isOpen={!!editing}
onClose={() => setEditing(null)}
location={editing}
brandId={brandId}
onSuccess={handleEdited}
/>
)}
</>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
stop_id: string;
status: string;
subtotal: number;
pickup_complete: boolean;
created_at: string;
};
type OrderStatusTone = "warning" | "info" | "success" | "danger" | "neutral";
const statusToTone: Record<string, OrderStatusTone> = {
pending: "warning",
confirmed: "info",
paid: "success",
cancelled: "danger",
completed: "neutral",
};
export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
);
const [error, setError] = useState<string | null>(null);
async function togglePickup(orderId: string, current: boolean) {
const next = !current;
setPickupToggles((prev) => ({ ...prev, [orderId]: next }));
setError(null);
const result = await toggleOrderPickupComplete({ orderId, pickupComplete: next });
if (!result.success) {
// Revert optimistic update on failure
setPickupToggles((prev) => ({ ...prev, [orderId]: current }));
setError(result.error);
}
}
return (
<tbody style={{ borderColor: "var(--admin-border)" }}>
{error && (
<tr>
<td
colSpan={6}
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-danger)" }}
>
{error}
</td>
</tr>
)}
{orders.map((order) => (
<tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-3 py-2">
<span
className="font-mono text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.id.slice(0, 8)}
</span>
</td>
<td className="px-3 py-2">
<div
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</div>
<div
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_email}
</div>
</td>
<td className="px-3 py-2">
<AdminBadge tone={statusToTone[order.status] ?? "neutral"}>
{order.status ?? "pending"}
</AdminBadge>
</td>
<td
className="px-3 py-2 font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
${Number(order.subtotal).toFixed(2)}
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className="transition-opacity hover:opacity-80"
>
<AdminBadge tone={pickupToggles[order.id] ? "success" : "neutral"} dot>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</AdminBadge>
</button>
</td>
<td
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(new Date(order.created_at))}
</td>
</tr>
))}
</tbody>
);
}
@@ -1,142 +0,0 @@
"use client";
import { useState } from "react";
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
export default function ProductAssignmentForm({
stopId,
allProducts,
assignedProductIds,
}: {
stopId: string;
allProducts: Product[];
assignedProductIds: string[];
}) {
const [selected, setSelected] = useState("");
const [loading, setLoading] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set(assignedProductIds));
const [error, setError] = useState<string | null>(null);
const availableProducts = allProducts.filter(
(p) => !assignedIds.has(p.id)
);
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setLoading(true);
setError(null);
const result = await assignProductToStop({ stopId, productId: selected });
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setAssignedIds((prev) => new Set(prev).add(selected));
setSelected("");
setLoading(false);
}
async function handleRemove(productId: string) {
const result = await unassignProductFromStop({ stopId, productId });
if (!result.success) {
setError(result.error);
return;
}
setAssignedIds((prev) => {
const next = new Set(prev);
next.delete(productId);
return next;
});
}
const assignedProducts = allProducts.filter((p) => assignedIds.has(p.id));
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Assigned products list */}
{assignedProducts.length > 0 && (
<div>
<p className="text-sm font-medium text-zinc-300 mb-3">
Currently assigned
</p>
<div className="space-y-2">
{assignedProducts.map((product) => (
<div
key={product.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 p-4"
>
<div>
<p className="font-medium text-zinc-100">{product.name}</p>
<p className="text-sm text-zinc-500">
{product.type} · ${product.price}
</p>
</div>
<button type="button"
onClick={() => handleRemove(product.id)}
className="text-sm text-red-400 hover:text-red-800"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Assign form */}
<form onSubmit={handleAssign} className="flex gap-3">
<select aria-label="Select"
value={selected}
onChange={(e) => setSelected(e.target.value)}
required
className="flex-1 rounded-xl border border-zinc-600 px-4 py-3 outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.name} {product.type} (${product.price})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || loading}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Assigning..." : "Assign Product"}
</button>
</form>
{availableProducts.length === 0 && assignedProducts.length > 0 && (
<p className="text-sm text-zinc-500">All products already assigned.</p>
)}
{allProducts.length === 0 && (
<p className="text-sm text-zinc-500">
No active products for this brand yet.
</p>
)}
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
"use client";
import { useState } from "react";
import { Product } from "@/types";
type Props = {
products: Product[];
onSearchChange: (s: string) => void;
onStatusChange: (f: "all" | "active" | "inactive") => void;
search: string;
statusFilter: "all" | "active" | "inactive";
count: number;
};
export default function ProductFilterBar({
products,
onSearchChange,
onStatusChange,
search,
statusFilter,
count,
}: Props) {
return (
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{count}</span>
</div>
);
}
-101
View File
@@ -1,101 +0,0 @@
"use client";
import { useState } from "react";
import { syncSquareNow, getSyncLog } from "@/actions/square-sync-ui";
import Link from "next/link";
type Props = {
brandId: string;
hasSquareToken: boolean;
};
export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [logs, setLogs] = useState<{ event_type: string; status: string; created_at: string }[]>([]);
async function handleSyncProducts() {
setSyncing(true);
setMsg(null);
const result = await syncSquareNow(brandId, "products");
setMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Products synced — ${result.synced} item(s).`
: `Failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
const logResult = await getSyncLog(brandId);
if (logResult.success) setLogs(logResult.logs.filter(l => l.entity_type === "product").slice(0, 5));
}
if (!hasSquareToken) {
return (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<span className="text-amber-700">Square not connected.</span>
<Link href="/admin/settings/payments" className="ml-2 font-medium text-emerald-600 hover:text-emerald-700 underline transition-colors">
Connect Square
</Link>
</div>
);
}
return (
<div className="mb-4">
<div className="flex items-center gap-3">
<button type="button"
onClick={handleSyncProducts}
disabled={syncing}
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
>
{syncing ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Syncing...
</span>
) : (
<span className="flex items-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sync Products to Square
</span>
)}
</button>
<Link
href="/admin/settings/square-sync"
className="text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Square Sync Settings
</Link>
</div>
{msg && (
<div className={`mt-2 rounded-lg border px-3 py-2 text-sm ${
msg.kind === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-red-200 bg-red-50 text-red-700"
}`}>
{msg.text}
</div>
)}
{logs.length > 0 && (
<div className="mt-2 space-y-1">
{logs.map((log, i) => (
<div key={`${log.event_type}-${log.status}-${log.created_at}`} className="flex items-center gap-2 text-xs text-stone-500">
<span className={`rounded px-1.5 py-0.5 font-medium ${
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
}`}>{log.status}</span>
<span className="text-stone-600">{log.event_type}</span>
<span className="text-stone-400">{new Date(log.created_at).toLocaleTimeString()}</span>
</div>
))}
</div>
)}
</div>
);
}
-249
View File
@@ -1,249 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteProduct } from "@/actions/products";
import AdminBadge from "./design-system/AdminBadge";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
brands: { name: string } | { name: string }[];
is_taxable?: boolean;
};
type ProductTableBodyProps = {
products: Product[];
search: string;
statusFilter: "all" | "active" | "inactive";
onSearchChange: (v: string) => void;
onStatusChange: (v: "all" | "active" | "inactive") => void;
onDeleted: (productId: string) => void;
};
export default function ProductTableBody({
products,
search,
statusFilter,
onSearchChange,
onStatusChange,
onDeleted,
}: ProductTableBodyProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
async function handleDelete(productId: string) {
setDeletingId(productId);
setDeleteError(null);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted(productId);
} else {
setDeleteError(result.error ?? "Delete failed");
}
}
return (
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-primary)]"
/>
<div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-[var(--admin-primary)] text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
</div>
{/* Delete error banner */}
{deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}
<button type="button"
onClick={() => setDeleteError(null)}
className="ml-2 underline hover:no-underline"
aria-label="Dismiss">
Dismiss
</button>
</div>
)}
<tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<tr
key={product.id}
className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
>
<td className="px-3 py-2">
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
>
{product.name}
</Link>
<div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div>
</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
<td
className="px-3 py-2 font-semibold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</td>
<td className="px-3 py-2">
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
</td>
<td className="px-3 py-2">
{product.is_taxable === false ? (
<AdminBadge tone="warning">Non-taxable</AdminBadge>
) : (
<AdminBadge tone="success">Taxable</AdminBadge>
)}
</td>
{/* Actions */}
<td className="px-3 py-2 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Edit
</Link>
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
<>
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 backdrop-blur-sm border-0 p-0 cursor-default"
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</>
);
}
-292
View File
@@ -1,292 +0,0 @@
"use client";
import React, { useState, useTransition, useCallback } from "react";
import Image from "next/image";
import { deleteProduct } from "@/actions/products";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
image_url?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
products: Product[];
};
export default function ProductTableClient({ products }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
const handleDeleted = useCallback(() => {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}, [router]);
return (
<>
{/* Filter bar */}
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button type="button"
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-sm text-stone-500 px-2">{filtered.length} products</span>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 bg-stone-50">
<tr>
<th className="px-5 py-4 font-semibold text-stone-500">Product</th>
<th className="px-5 py-4 font-semibold text-stone-500">Brand</th>
<th className="px-5 py-4 font-semibold text-stone-500">Type</th>
<th className="px-5 py-4 font-semibold text-stone-500">Price</th>
<th className="px-5 py-4 font-semibold text-stone-500">Status</th>
<th className="px-5 py-4 font-semibold text-stone-500" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-16 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<ProductRow
key={product.id}
product={product}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function ProductRowBase({
product,
onDeleted,
onDeleteError,
}: {
product: Product;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
async function handleDelete(productId: string) {
setDeletingId(productId);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{product.image_url ? (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-stone-200">
<Image
src={product.image_url}
alt={product.name}
fill
sizes="48px"
style={{ objectFit: "cover" }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
) : (
<div className="h-12 w-12 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-stone-200">
<span className="text-stone-400 text-lg"></span>
</div>
)}
<div>
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{product.name}
</Link>
<div className="text-stone-500 line-clamp-1 text-sm">
{product.description || (
<span className="italic text-stone-400">No description</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-stone-600 font-mono text-xs uppercase tracking-wide">{product.type}</td>
<td className="px-5 py-4 font-mono font-semibold text-stone-900">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
<>
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-stone-500">
This will remove the product. If it is attached to any orders,
it will be hidden instead of deleted.
</p>
<div className="mt-4 flex gap-2">
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const ProductRow = React.memo(ProductRowBase);
@@ -1,9 +0,0 @@
"use client";
import SquareSyncWidget from "@/components/admin/SquareSyncWidget";
type Props = { brandId: string };
export default function SquareSyncWidgetWrapper({ brandId }: Props) {
return <SquareSyncWidget brandId={brandId} />;
}
-31
View File
@@ -1,31 +0,0 @@
type Stat = {
label: string;
value: number | string;
emphasis?: boolean;
};
type Props = {
stats: Stat[];
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
right?: React.ReactNode;
};
export default function StatsStrip({ stats, right }: Props) {
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{stats.map((s, i) => (
<span key={`${s.label}-${s.value}`} className="flex items-baseline gap-1.5">
<span
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
>
{s.value}
</span>
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
</span>
))}
</div>
{right && <div className="ml-auto">{right}</div>}
</div>
);
}
-394
View File
@@ -1,394 +0,0 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getStopDetails } from "@/actions/stops/get-stop-details";
import { useToast } from "@/components/admin/design-system";
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
type Tab = "details" | "products" | "message";
type Props = {
stopId: string;
/** Optional: when the user clicks Duplicate from inside the modal. */
onDuplicate?: (stopId: string) => void;
onClose: () => void;
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
return (
<GlassModal
title="Stop"
subtitle="Stop details"
onClose={onClose}
maxWidth="max-w-3xl"
>
{/* Keying by stopId causes a full remount + fresh data fetch
whenever the target stop changes, eliminating the need to
reset internal state in an effect. */}
<StopDetailContent
key={stopId}
stopId={stopId}
onDuplicate={onDuplicate}
onClose={onClose}
/>
</GlassModal>
);
}
function StopDetailContent({
stopId,
onDuplicate,
onClose: _onClose,
}: {
stopId: string;
onDuplicate?: (stopId: string) => void;
onClose: () => void;
}) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
// Group the load result into a single state object so the effect
// only writes one piece of state at a time (satisfies
// `no-cascading-set-state`).
const [loadData, setLoadData] = useState<{
loading: boolean;
loadError: string | null;
stop: StopDetail | null;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
callerUid: string;
}>({
loading: true,
loadError: null,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
});
const { loading, loadError, stop, allProducts, assignedProducts, brands, callerUid } = loadData;
const [tab, setTab] = useState<Tab>("details");
// Track the last stopId we kicked off a fetch for. When the prop
// changes we flip `loading` inline during render so users never see
// stale "loaded" UI between the prop change and the effect running.
const [lastFetchedStopId, setLastFetchedStopId] = useState<string | null>(null);
if (stopId !== lastFetchedStopId) {
setLastFetchedStopId(stopId);
setLoadData({
loading: true,
loadError: null,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
});
}
useEffect(() => {
let cancelled = false;
void (async () => {
let nextState: typeof loadData;
try {
const res = await getStopDetails(stopId);
if (cancelled) return;
if (!res.success) {
nextState = {
loading: false,
loadError: res.error,
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
};
} else {
nextState = {
loading: false,
loadError: null,
stop: res.stop,
allProducts: res.allProducts,
assignedProducts: res.assignedProducts,
brands: res.brands,
callerUid: res.callerUid,
};
}
} catch (err) {
if (cancelled) return;
nextState = {
loading: false,
loadError: err instanceof Error ? err.message : "Failed to load stop",
stop: null,
allProducts: [],
assignedProducts: [],
brands: [],
callerUid: "",
};
}
setLoadData(nextState);
})();
return () => {
cancelled = true;
};
}, [stopId]);
function refresh() {
getStopDetails(stopId).then((res) => {
if (!res.success) return;
setLoadData((prev) => ({
...prev,
stop: res.stop,
allProducts: res.allProducts,
assignedProducts: res.assignedProducts,
brands: res.brands,
callerUid: res.callerUid,
}));
});
}
function handleEditSaved() {
refresh();
showSuccess("Stop updated", "Changes have been saved");
startTransition(() => router.refresh());
}
const subtitle = stop?.brands
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
: undefined;
return (
<>
<div className="mb-3 hidden">
<span>{subtitle}</span>
</div>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-1/2 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
</div>
) : loadError ? (
<div
className="rounded-xl border px-4 py-3 text-sm text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
borderColor: "color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
Details
</TabButton>
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
<span className="inline-flex items-center gap-1.5">
Products
{assignedProducts.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
{assignedProducts.length}
</span>
)}
</span>
</TabButton>
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
Message
</TabButton>
</div>
{tab === "details" && (
<DetailsPanel
stop={stop}
brands={brands}
onDuplicate={onDuplicate}
onSaved={handleEditSaved}
/>
)}
{tab === "products" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Assigned Products
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Manage which products are available at this stop.
</p>
<div className="mt-4">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={callerUid}
/>
</div>
</div>
)}
{tab === "message" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Message Customers
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-4">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
)}
</div>
) : null}
</>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
}`}
>
{children}
</button>
);
}
function DetailsPanel({
stop,
brands,
onDuplicate,
onSaved,
}: {
stop: StopDetail;
brands: { id: string; name: string; slug: string }[];
onDuplicate?: (stopId: string) => void;
onSaved: () => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
</div>
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
</div>
{stop.address && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</dd>
</div>
)}
{stop.cutoff_time && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_time).toLocaleString()}
</dd>
</div>
)}
</dl>
</div>
<div className="flex justify-end">
{onDuplicate ? (
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</a>
)}
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Update stop details, location, and availability.
</p>
<div className="mt-4">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.slug,
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brands}
onSaved={onSaved}
/>
</div>
</div>
</div>
);
}
-240
View File
@@ -1,240 +0,0 @@
"use client";
import { useState } from "react";
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast";
import { AdminButton } from "@/components/admin/design-system";
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
];
export default function StopMessagingForm({
stopId,
brandId,
}: {
stopId: string;
brandId: string;
}) {
const [customers, setCustomers] = useState<StopCustomer[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [message, setMessage] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(0);
const [error, setError] = useState<string | null>(null);
async function loadCustomers() {
setLoading(true);
setError(null);
const result = await getStopPendingCustomers(stopId);
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setCustomers(result.customers);
setLoaded(true);
setLoading(false);
}
function applyQuickMessage(msg: string) {
setMessage(msg);
setCustomMessage(msg);
}
async function handleSend() {
if (!message.trim()) return;
setSending(true);
setError(null);
const blast = await sendStopBlast({
stopId,
brandId,
channel,
body: message,
audience: "pending",
});
if (!blast.success) {
setError(blast.error);
setSending(false);
return;
}
setSent(blast.messages_logged);
setSending(false);
}
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
return (
<div className="space-y-6">
<div>
<h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
Send Message
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Notify all customers with pending pickups at this stop.
</p>
</div>
{!loaded ? (
<button type="button"
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
>
{loading ? "Loading customers..." : "Load Pending Customers"}
</button>
) : (
<>
{customers.length === 0 ? (
<div className="rounded-xl bg-[var(--admin-bg-subtle)] p-6 text-center text-[var(--admin-text-muted)]">
No pending orders for this stop yet.
</div>
) : (
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span>
)}
</div>
)}
{/* Channel */}
<div>
<p className="ha-field-label mb-2">
<span>Send via</span>
</p>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button type="button"
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{ch === "sms" ? "SMS" : ch === "email" ? "Email" : "Both"}
</button>
))}
</div>
</div>
{/* Quick messages */}
<div>
<p className="ha-field-label mb-2">
<span>Quick messages</span>
</p>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
<button type="button"
key={qm.label}
onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{qm.label}
</button>
))}
</div>
</div>
{/* Message preview */}
<div>
<label htmlFor="fld-stop-message" className="ha-field-label mb-2">
<span>Message</span>
</label>
<textarea id="fld-stop-message" aria-label="Type Your Message..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
setCustomMessage(e.target.value);
}}
rows={4}
className="ha-field-textarea"
placeholder="Type your message..."
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)] tabular-nums">
{message.length} characters
</p>
</div>
{error && (
<div
role="alert"
className="rounded-xl p-4 text-sm text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)" }}
>
{error}
</div>
)}
{sent > 0 && (
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div>
)}
{/* Recipients */}
{recipients.length > 0 && message && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
Recipients ({recipients.length}):
</p>
<div className="space-y-2">
{recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
<span className="text-[var(--admin-text-muted)] tabular-nums">
{c.customer_phone ?? c.customer_email ?? "no contact"}
</span>
</div>
))}
</div>
</div>
)}
<AdminButton
variant="primary"
size="lg"
onClick={handleSend}
disabled={!message || recipients.length === 0 || sending}
isLoading={sending}
className="w-full"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</AdminButton>
</>
)}
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
"use client";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brands: { name: string } | { name: string }[];
};
export default function StopTableBody({ stops }: { stops: Stop[] }) {
return (
<tbody className="divide-y divide-slate-200">
{stops?.map((stop) => (
<tr
key={stop.id}
onClick={() =>
(window.location.href = `/admin/stops/${stop.id}`)
}
className="cursor-pointer hover:bg-zinc-800"
>
<td className="px-3 py-2">
<span className="font-medium text-zinc-100">
{stop.city}, {stop.state}
</span>
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.location}
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.date}
</td>
<td className="px-3 py-2 text-zinc-300">
{stop.time}
</td>
<td className="px-3 py-2 text-zinc-300">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-3 py-2">
<span className="rounded-full bg-zinc-950 px-3 py-1 text-xs font-medium text-zinc-300">
{stop.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
);
}
@@ -1,754 +0,0 @@
"use client";
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { useToast } from "@/components/admin/design-system";
import type { StopForView } from "./StopsViewClient";
type Props = {
stops: StopForView[];
};
/* ================================================================== */
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
/* ================================================================== */
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmd(s: string): Date | null {
// Treat YYYY-MM-DD as a local date (no UTC shift)
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const [, y, mo, d] = m;
return new Date(Number(y), Number(mo) - 1, Number(d));
}
function todayYmd(): string {
return ymd(new Date());
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const MONTH_NAMES_ITALIC = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function buildMonthGrid(month: Date): Date[] {
// Start at the Sunday on or before the 1st of the month
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const startDayOfWeek = first.getDay(); // 0 = Sun
const start = new Date(first);
start.setDate(1 - startDayOfWeek);
// 6 weeks = 42 cells
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
if (stop.status === "draft") return "draft";
if (stop.active) return "active";
return "inactive";
}
function statusLabel(s: "active" | "draft" | "inactive"): string {
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
}
function brandName(s: StopForView): string {
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
}
function formatTime12(t: string | null | undefined): string {
if (!t) return "—";
// t is "HH:MM" or "HH:MM:SS"
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "pm" : "am";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr} ${ampm}`;
}
function formatTimeCompact(t: string | null | undefined): string {
if (!t) return "—";
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "p" : "a";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr}${ampm}`;
}
function formatDateLong(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}`;
}
function formatDateLongSpelled(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
}
/* ================================================================== */
/* Component */
/* ================================================================== */
const MAX_EVENTS_PER_CELL = 3;
export default function StopsCalendarClient({ stops }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [viewMonth, setViewMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activePopover, setActivePopover] = useState<{
stopId: string;
cellRect: DOMRect;
} | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
// Bucket stops by YYYY-MM-DD
const stopsByDate = useMemo(() => {
const map = new Map<string, StopForView[]>();
for (const s of stops) {
if (!s.date) continue;
const list = map.get(s.date) ?? [];
list.push(s);
map.set(s.date, list);
}
// Sort each day by time
for (const list of map.values()) {
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const todayStr = useMemo(() => todayYmd(), []);
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
// Earliest/latest stop dates — used to enable/disable nav
const dateRange = useMemo(() => {
if (stops.length === 0) return null;
const dates = stops.flatMap((s) => (s.date ? [s.date] : [])).sort();
return { min: dates[0], max: dates[dates.length - 1] };
}, [stops]);
const isFirstMonth = useMemo(() => {
if (!dateRange?.min) return false;
const minDate = parseYmd(dateRange.min);
if (!minDate) return false;
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
}, [dateRange, viewMonth]);
const isLastMonth = useMemo(() => {
if (!dateRange?.max) return false;
const maxDate = parseYmd(dateRange.max);
if (!maxDate) return false;
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
}, [dateRange, viewMonth]);
function goPrevMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
}
function goNextMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
}
function goToday() {
const today = new Date();
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
setSelectedDate(todayYmd());
}
// Close popover on outside click / Escape
useEffect(() => {
if (!activePopover) return;
function onDown(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
setActivePopover(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setActivePopover(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [activePopover]);
// Close drawer on Escape
useEffect(() => {
if (!selectedDate) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setSelectedDate(null);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [selectedDate]);
// Lock body scroll when drawer is open
useEffect(() => {
if (selectedDate) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [selectedDate]);
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
}, []);
const handlePublish = useCallback(
async (stop: StopForView) => {
setActivePopover(null);
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
startTransition(() => router.refresh());
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
},
[router, showSuccess, showError]
);
const activeStop = useMemo(
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
[activePopover, stops]
);
const selectedDayStops = useMemo(() => {
if (!selectedDate) return [];
return stopsByDate.get(selectedDate) ?? [];
}, [selectedDate, stopsByDate]);
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
// Compute popover position with viewport edge detection
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover) return null;
const POPOVER_W = 288; // 18rem
const MARGIN = 8;
const rect = activePopover.cellRect;
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
// Default: place below the chip, left-aligned
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
const top = rect.bottom + MARGIN;
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
if (left < 8) left = 8;
return { left, top };
}, [activePopover]);
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover || !popoverStyle) return null;
const rect = activePopover.cellRect;
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
}, [activePopover, popoverStyle]);
// === Render ===
return (
<>
<div className="ha-calendar">
{/* Header */}
<div className="ha-calendar-header">
<div className="ha-calendar-title-block">
<span className="ha-calendar-eyebrow">
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
</span>
<h2 className="ha-calendar-title">
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
</h2>
</div>
<div className="ha-calendar-nav">
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goPrevMonth}
disabled={isFirstMonth}
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button" className="ha-calendar-today" onClick={goToday}>
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
Today
</button>
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goNextMonth}
disabled={isLastMonth}
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* DOW header */}
<div className="ha-calendar-dow" role="row">
{DOW_LABELS.map((label) => (
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
{label}
</div>
))}
</div>
{/* Grid */}
<div className="ha-calendar-grid" role="grid">
{monthCells.map((d) => {
const key = ymd(d);
const dayStops = stopsByDate.get(key) ?? [];
const isOut = d.getMonth() !== viewMonth.getMonth();
const isToday = key === todayStr;
const hasStops = dayStops.length > 0;
const dow = d.getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = selectedDate === key;
return (
<div
key={key}
role="gridcell"
tabIndex={hasStops ? 0 : -1}
onClick={() => hasStops && setSelectedDate(key)}
onKeyDown={(e) => {
if (hasStops && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setSelectedDate(key);
}
}}
className={[
"ha-calendar-cell",
isOut ? "ha-calendar-cell--out" : "",
isWeekend ? "ha-calendar-cell--weekend" : "",
hasStops ? "ha-calendar-cell--has-stops" : "",
isToday ? "ha-calendar-cell--today" : "",
isSelected ? "ha-calendar-cell--selected" : "",
]
.filter(Boolean)
.join(" ")}
aria-label={hasStops ? `${formatDateLong(d)}${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
>
<div className="ha-calendar-daynum">
<span>{d.getDate()}</span>
{isToday && <span className="ha-calendar-today-dot" />}
</div>
<div className="ha-calendar-events">
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
<EventChip
key={stop.id}
stop={stop}
isPublishing={publishingId === stop.id}
onOpen={(rect) => openStop(stop, rect)}
isActive={activePopover?.stopId === stop.id}
/>
))}
{dayStops.length > MAX_EVENTS_PER_CELL && (
<button
type="button"
className="ha-calendar-event-more"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(key);
}}
>
+{dayStops.length - MAX_EVENTS_PER_CELL} more
</button>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="ha-calendar-legend">
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
Active
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-warning)" }} />
Draft
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
Inactive
</span>
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
Tip click any day with stops to see the full route · click an event for details
</span>
</div>
</div>
{/* Event popover */}
{activeStop && activePopover && popoverStyle && (
<EventPopover
stop={activeStop}
isPublishing={publishingId === activeStop.id}
onPublish={() => handlePublish(activeStop)}
onOpenRoute={() => {
setActivePopover(null);
setSelectedDate(activeStop.date);
}}
style={popoverStyle}
arrowStyle={popoverArrowStyle}
/>
)}
{/* Day-route drawer */}
{selectedDate && selectedDayDate && (
<DayRouteDrawer
date={selectedDayDate}
dateStr={selectedDate}
stops={selectedDayStops}
onClose={() => setSelectedDate(null)}
onPublish={handlePublish}
/>
)}
</>
);
}
/* ================================================================== */
/* EventChip — single stop on a day cell */
/* ================================================================== */
function EventChip({
stop,
onOpen,
isActive,
isPublishing,
}: {
stop: StopForView;
onOpen: (rect: DOMRect) => void;
isActive: boolean;
isPublishing: boolean;
}) {
const ref = useRef<HTMLButtonElement>(null);
const status = statusOf(stop);
return (
<button
ref={ref}
type="button"
data-event-chip
onClick={(e) => {
e.stopPropagation();
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
}}
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
title={`${stop.city}, ${stop.state}${stop.location}`}
>
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
</button>
);
}
/* ================================================================== */
/* EventPopover — floating detail card */
/* ================================================================== */
function EventPopover({
stop,
isPublishing,
onPublish,
onOpenRoute,
style,
arrowStyle,
}: {
stop: StopForView;
isPublishing: boolean;
onPublish: () => void;
onOpenRoute: () => void;
style: React.CSSProperties;
arrowStyle: React.CSSProperties | null;
}) {
const status = statusOf(stop);
return (
<div
data-event-popover
className="ha-event-popover"
style={style}
onClick={(e) => e.stopPropagation()}
>
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
<div className="ha-event-popover-eyebrow">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "var(--admin-warning)" : "var(--admin-text-muted)",
}}
/>
{statusLabel(status)} · {brandName(stop)}
</div>
<h3 className="ha-event-popover-title">
{stop.city}, {stop.state}
</h3>
<p className="ha-event-popover-location">{stop.location}</p>
<div className="ha-event-popover-grid">
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Pickup</span>
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
</div>
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Cutoff</span>
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
</div>
{stop.address && (
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
<span className="ha-event-popover-field-label">Address</span>
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
</div>
)}
</div>
<div className="ha-event-popover-actions">
{status === "draft" && (
<button
type="button"
onClick={onPublish}
disabled={isPublishing}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
{isPublishing ? "Publishing…" : "Publish"}
</button>
)}
<button
type="button"
onClick={onOpenRoute}
className="ha-event-popover-btn"
>
View route
</button>
<Link
href={`/admin/stops/${stop.id}`}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
);
}
/* ================================================================== */
/* DayRouteDrawer — full day's route on the side */
/* ================================================================== */
function DayRouteDrawer({
date,
dateStr,
stops,
onClose,
onPublish,
}: {
date: Date;
dateStr: string;
stops: StopForView[];
onClose: () => void;
onPublish: (stop: StopForView) => void;
}) {
const sorted = useMemo(() => stops.toSorted((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const counts = useMemo(() => {
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
const draft = sorted.filter((s) => s.status === "draft").length;
const total = sorted.length;
return { active, draft, total };
}, [sorted]);
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
const routeNumber = useMemo(() => {
// Editorial "Route 03" — count of stops with status badge
return String(sorted.length).padStart(2, "0");
}, [sorted.length]);
const earliest = sorted[0]?.time;
const latest = sorted[sorted.length - 1]?.time;
return (
<>
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
<header className="ha-drawer-header">
<div>
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
<p className="ha-drawer-subtitle">
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
{earliest && latest && sorted.length > 1 && (
<> · {formatTime12(earliest)} {formatTime12(latest)}</>
)}
</p>
</div>
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="ha-drawer-body">
{sorted.length === 0 ? (
<div className="ha-drawer-empty">
<p className="ha-drawer-empty-mark">No route on this day</p>
<p className="ha-drawer-empty-text">
No stops are scheduled for {formatDateLongSpelled(date)}.
</p>
</div>
) : (
<>
{/* Summary cards */}
<div className="ha-route-summary">
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.total}</span>
<span className="ha-route-summary-label">Stops</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.active}</span>
<span className="ha-route-summary-label">Live</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{brands.length}</span>
<span className="ha-route-summary-label">Brands</span>
</div>
</div>
{/* Route spine */}
<div className="ha-route-list">
{sorted.map((stop, idx) => {
const status = statusOf(stop);
return (
<article key={stop.id} className="ha-route-stop">
<div className="ha-route-stop-spine">
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
{String(idx + 1).padStart(2, "0")}
</div>
<div className="ha-route-stop-line" />
</div>
<div className="ha-route-stop-content">
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
<h3 className="ha-route-stop-name">
{stop.city}, {stop.state}
</h3>
<p className="ha-route-stop-location">{stop.location}</p>
<div className="ha-route-stop-meta">
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
<span
className="ha-route-stop-meta-pill"
style={{
background:
status === "active"
? "var(--admin-accent-light)"
: status === "draft"
? "var(--admin-warning-soft)"
: "var(--admin-bg-subtle)",
color:
status === "active"
? "var(--admin-accent-text)"
: status === "draft"
? "var(--admin-warning)"
: "var(--admin-text-secondary)",
borderColor:
status === "active"
? "var(--admin-accent)"
: status === "draft"
? "var(--admin-warning)"
: "var(--admin-border-light)",
}}
>
{statusLabel(status)}
</span>
{stop.cutoff_time && (
<span className="ha-route-stop-meta-pill">
Cutoff {formatTime12(stop.cutoff_time)}
</span>
)}
{stop.address && (
<span className="ha-route-stop-meta-pill">
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
)}
</div>
<div className="ha-route-stop-actions">
{status === "draft" && (
<button
type="button"
onClick={() => onPublish(stop)}
className="ha-route-stop-action ha-route-stop-action--primary"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Publish
</button>
)}
<Link
href={`/admin/stops/${stop.id}`}
className="ha-route-stop-action ha-route-stop-action--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
className="ha-route-stop-action"
>
Duplicate
</Link>
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
</aside>
</>
);
}
@@ -1,73 +0,0 @@
"use client";
import { useState } from "react";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { useRouter } from "next/navigation";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
/** Hide on the Locations tab — actions belong to the Stops tab only. */
tab?: "stops" | "locations";
};
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const router = useRouter();
if (tab !== "stops") return null;
function handleImportComplete(count: number) {
router.refresh();
}
function handleAddSuccess(stopId: string) {
router.refresh();
}
return (
<>
<div className="flex gap-3">
<AdminButton
variant="secondary"
onClick={() => setShowImport(true)}
icon={
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
>
Upload Schedule
</AdminButton>
<AdminButton
variant="primary"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Stop
</AdminButton>
</div>
{showImport && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setShowImport(false)}
onComplete={handleImportComplete}
/>
)}
<AddStopModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAddSuccess}
/>
</>
);
}
-107
View File
@@ -1,107 +0,0 @@
"use client";
import Link from "next/link";
type Props = {
active: "stops" | "locations";
stopCount: number;
locationCount: number;
stopsSublabel?: string; // e.g. "269 · 7 cities"
locationsSublabel?: string; // e.g. "41 · 8 cities"
};
const StopIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
const PinIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function StopsLocationsTabs({
active,
stopCount,
locationCount,
stopsSublabel,
locationsSublabel,
}: Props) {
const tabs = [
{
value: "stops" as const,
label: "Stops",
count: stopCount,
sublabel: stopsSublabel,
icon: <StopIcon />,
href: "/admin/stops",
},
{
value: "locations" as const,
label: "Locations",
count: locationCount,
sublabel: locationsSublabel,
icon: <PinIcon />,
href: "/admin/stops?tab=locations",
},
];
return (
<div
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
role="tablist"
aria-label="Stops and Locations tabs"
>
{tabs.map((t) => {
const isActive = t.value === active;
return (
<Link
key={t.value}
href={t.href}
role="tab"
aria-selected={isActive}
className={`
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200
${isActive
? "bg-white text-[var(--admin-primary)] border border-[var(--admin-primary)]/30 shadow-sm"
: "text-[var(--admin-text-secondary)] border border-transparent hover:text-[var(--admin-primary)] hover:bg-[var(--admin-primary-soft)]/40"
}
`}
>
<span
className={`flex-shrink-0 transition-colors ${isActive ? "text-[var(--admin-primary)]" : "text-[var(--admin-text-muted)] group-hover:text-[var(--admin-primary)]"}`}
aria-hidden
>
{t.icon}
</span>
<span>{t.label}</span>
<span
className={`
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums
${isActive
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] group-hover:bg-[var(--admin-primary-soft)] group-hover:text-[var(--admin-primary)]"
}
`}
>
{t.count}
</span>
{t.sublabel && (
<span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-[var(--admin-text-muted)]" : "text-[var(--admin-text-muted)]/80"}`}
>
· {t.sublabel}
</span>
)}
</Link>
);
})}
</div>
);
}
-132
View File
@@ -1,132 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import StopTableClient from "@/components/admin/StopTableClient";
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
export type StopsViewMode = "calendar" | "table";
export type StopForView = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: StopForView[];
};
export default function StopsViewClient({ stops }: Props) {
const [view, setView] = useState<StopsViewMode>("calendar");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
// Apply shared filter so both views agree on what's visible
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const isDraft = s.status === "draft";
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && !isDraft) ||
(statusFilter === "inactive" && !s.active && !isDraft) ||
(statusFilter === "draft" && isDraft);
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const counts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && s.status !== "draft").length,
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
{ value: "draft", label: "Draft", count: counts.draft },
];
return (
<>
{/* Filter / search / view-toggle bar — shared across both views */}
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
tabs={tabs}
size="sm"
showCounts
/>
<AdminSearchInput
placeholder="Search city, state, or venue…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
</span>
<div className="sm:ml-auto">
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
<button
type="button"
role="tab"
aria-selected={view === "calendar"}
onClick={() => setView("calendar")}
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
Calendar
</button>
<button
type="button"
role="tab"
aria-selected={view === "table"}
onClick={() => setView("table")}
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
Table
</button>
</div>
</div>
</div>
{view === "calendar" ? (
<StopsCalendarClient stops={filtered} />
) : (
<StopTableClient stops={filtered} hideInternalFilterBar />
)}
</>
);
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
type Props = {
tabKey: string;
children: React.ReactNode;
};
/**
* Fades the content of a tabbed panel when the active tab changes.
* The tab key drives the animation, so switching tabs triggers a quick
* opacity-only fade-in of the new content.
*
* Motion note: the previous version used `y: 4` / `y: -4` on enter/exit
* for a small slide. That 4px slide, multiplied across every tab change
* in the admin, was a steady source of positional movement. Now it's
* pure opacity, 120ms, ease-out. `reducedMotion="user"` on the global
* <MotionConfig> will further strip this to instant if the user has
* prefers-reduced-motion enabled.
*
* No motion when the tab key is stable (i.e. on first render of a given tab).
*/
export function TabSwitcher({ tabKey, children }: Props) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tabKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: "easeOut" }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
@@ -1,95 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getTaxSummaryAction } from "@/actions/tax";
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
};
function quarterLabel(): string {
const now = new Date();
const q = Math.floor(now.getMonth() / 3) + 1;
return `Q${q} ${now.getFullYear()}`;
}
function quarterDateRange(): { start: string; end: string } {
const now = new Date();
const q = Math.floor(now.getMonth() / 3);
const startDate = new Date(now.getFullYear(), q * 3, 1);
const end = now.toISOString().slice(0, 10);
return { start: startDate.toISOString().slice(0, 10), end };
}
export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
const [data, setData] = useState<TaxSummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const range = quarterDateRange();
let cancelled = false;
(async () => {
const result = await getTaxSummaryAction({
brandId,
startDate: range.start,
endDate: range.end,
});
if (cancelled) return;
if (result.success) {
setData({
total_tax_collected: result.data.total_tax_collected,
total_gross_sales: result.data.total_gross_sales,
order_count: result.data.order_count,
});
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-xs text-[var(--admin-text-secondary)]">Loading tax summary...</span>
</div>
);
}
if (!data || data.order_count === 0) return null;
const effectiveRate = data.total_gross_sales > 0
? (data.total_tax_collected / data.total_gross_sales) * 100
: 0;
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]">
{quarterLabel()} Tax Collected
</span>
</div>
<Link
href="/admin/taxes"
className="text-xs text-[var(--admin-accent-text)] hover:text-[var(--admin-accent)] font-medium transition-colors"
>
View Details
</Link>
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-xl font-bold text-[var(--admin-text-primary)]">
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
<span className="text-xs text-[var(--admin-text-muted)]">
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
</span>
</div>
</div>
);
}
@@ -1,79 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
};
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
return (
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
/>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
<div>
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
>
<svg aria-hidden="true" className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
</dialog>
);
}
@@ -1,6 +0,0 @@
// Toast notification system - re-exports for design-system
// The actual implementations are in the parent admin directory
// Use: import { ToastProvider, useToast, useToastActions, ToastContainer } from "@/components/admin/Toast";
export { ToastProvider, useToast, useToastActions } from "@/components/admin/Toast";
export { ToastContainer, InlineToast } from "@/components/admin/ToastContainer";
-123
View File
@@ -1,123 +0,0 @@
"use client";
import { useState } from "react";
type Column<T> = {
key: keyof T | string;
header: string;
render?: (item: T) => React.ReactNode;
className?: string;
align?: "left" | "right" | "center";
};
type DataTableProps<T> = {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowClassName?: string;
paginate?: boolean;
pageSize?: number;
};
export default function DataTable<T>({
data,
columns,
keyExtractor,
emptyMessage = "No data found",
onRowClick,
rowClassName,
paginate = false,
pageSize = 50,
}: DataTableProps<T>) {
const [page, setPage] = useState(0);
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
const totalPages = Math.ceil(data.length / pageSize);
const alignClass = (align?: "left" | "right" | "center") => {
switch (align) {
case "right": return "text-right";
case "center": return "text-center";
default: return "text-left";
}
};
return (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50">
{columns.map((col) => (
<th
key={String(col.key)}
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{displayed.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
{emptyMessage}
</td>
</tr>
) : (
displayed.map((item) => (
<tr
key={keyExtractor(item)}
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
onRowClick ? "cursor-pointer" : ""
} ${rowClassName ?? ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<td
key={String(col.key)}
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{paginate && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
<span className="text-xs text-stone-500">
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
"use client";
type FilterOption = {
value: string;
label: string;
};
type FilterBarProps = {
searchPlaceholder?: string;
searchValue: string;
onSearchChange: (value: string) => void;
filters?: {
label?: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}[];
tabs?: {
label: string;
value: string;
count?: number;
}[];
activeTab?: string;
onTabChange?: (value: string) => void;
actions?: React.ReactNode;
resultCount?: number;
showCount?: boolean;
};
export default function FilterBar({
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
tabs = [],
activeTab,
onTabChange,
actions,
resultCount,
showCount = true,
}: FilterBarProps) {
return (
<div className="space-y-3">
{/* Search + Filters Row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<input aria-label="Search"
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
</div>
{filters.map((filter, i) => (
<div key={`${filter.label ?? ""}-${filter.value}-${i}`} className="space-y-1">
{filter.label && (
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
)}
<select aria-label="Select"
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
{actions && <div className="flex items-center gap-2">{actions}</div>}
{showCount && resultCount !== undefined && (
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
)}
</div>
{/* Tabs Row */}
{tabs.length > 0 && (
<div className="flex gap-1 border-b border-stone-200 pb-0">
{tabs.map((tab) => (
<button type="button"
key={tab.value}
onClick={() => onTabChange?.(tab.value)}
className={`
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
${activeTab === tab.value
? "text-emerald-600 border-emerald-600"
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
}
`}
>
{tab.label}
{tab.count !== undefined && (
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,42 +0,0 @@
"use client";
import Link from "next/link";
type PageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
title: string;
description?: string;
action?: React.ReactNode;
};
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
return (
<div className="mb-8">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
{breadcrumb.map((crumb, i) => (
<span key={`${crumb.label}-${i}`} className="flex items-center gap-2">
{crumb.href ? (
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-stone-600">{crumb.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
{description && (
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
</div>
);
}
@@ -1,51 +0,0 @@
type StatusBadgeProps = {
status: string;
size?: "sm" | "md";
};
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
// Active/Enabled states
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
// Pending/Warning states
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
// Inactive/Disabled states
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
// Info states
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
// Special states
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
};
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
const config = STATUS_STYLES[status] ?? {
bg: "bg-stone-100",
text: "text-stone-600",
label: status.replace(/_/g, " "),
};
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
return (
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
{config.label}
</span>
);
}
-4
View File
@@ -1,4 +0,0 @@
export { default as PageHeader } from "./PageHeader";
export { default as StatusBadge } from "./StatusBadge";
export { default as FilterBar } from "./FilterBar";
export { default as DataTable } from "./DataTable";
@@ -1,432 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type Stop,
type StopStatus,
formatMonthYear,
formatTime12,
getStopDate,
getStopStatus,
isSameDay,
} from "./types";
type Props = {
stops: Stop[];
};
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
active: {
bg: "bg-[var(--admin-accent-light)]",
text: "text-[var(--admin-accent-text)]",
dot: "bg-[var(--admin-accent-dot)]",
border: "border-[var(--admin-accent)]/40",
},
draft: {
bg: "bg-amber-50",
text: "text-amber-800",
dot: "bg-amber-500",
border: "border-amber-300",
},
inactive: {
bg: "bg-stone-100",
text: "text-stone-500",
dot: "bg-stone-400",
border: "border-stone-300",
},
};
function buildMonthGrid(viewYear: number, viewMonth: number) {
// Sunday-first 6-row grid
const first = new Date(viewYear, viewMonth, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells: { date: Date; inMonth: boolean }[] = [];
// Leading days from previous month
for (let i = startWeekday - 1; i >= 0; i--) {
const d = new Date(viewYear, viewMonth, -i);
cells.push({ date: d, inMonth: false });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
}
// Trailing days to fill 6 rows = 42 cells
while (cells.length < 42) {
const last = cells[cells.length - 1].date;
const next = new Date(last);
next.setDate(last.getDate() + 1);
cells.push({ date: next, inMonth: false });
}
return cells;
}
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export default function StopsCalendar({ stops }: Props) {
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [selectedDate, setSelectedDate] = useState<string | null>(() => ymd(today));
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
const stopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
const k = ymd(d);
const arr = map.get(k) ?? [];
arr.push(s);
map.set(k, arr);
}
// sort each bucket by time
for (const arr of map.values()) {
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const visibleStopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const [k, arr] of stopsByDate.entries()) {
map.set(
k,
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
);
}
return map;
}, [stopsByDate]);
const monthStopsCount = useMemo(() => {
let count = 0;
for (const cell of cells) {
if (!cell.inMonth) continue;
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
}
return count;
}, [cells, stopsByDate]);
const upcomingWeek = useMemo(() => {
const end = new Date(today);
end.setDate(end.getDate() + 7);
let count = 0;
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
}
return count;
}, [stops, today]);
const goPrev = () => {
setView((v) => {
const m = v.month - 1;
if (m < 0) return { year: v.year - 1, month: 11 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goNext = () => {
setView((v) => {
const m = v.month + 1;
if (m > 11) return { year: v.year + 1, month: 0 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goToday = () => {
setView({ year: today.getFullYear(), month: today.getMonth() });
setSelectedDate(ymd(today));
};
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
{/* Calendar card */}
<section
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
aria-label="Stops calendar"
>
{/* Decorative paper texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.4]"
style={{
backgroundImage:
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
{/* Header */}
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
<div className="flex items-baseline gap-4">
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
{monthLabel.split(" ")[0]}
<span className="text-[var(--admin-accent)] italic font-light">
{" "}
{monthLabel.split(" ")[1]}
</span>
</h2>
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
</span>
</div>
<div className="flex items-center gap-1.5">
<button type="button"
onClick={goPrev}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<button type="button"
onClick={goToday}
disabled={isCurrentMonth}
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Today
</button>
<button type="button"
onClick={goNext}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</header>
{/* Weekday header */}
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
{WEEKDAY_LABELS.map((d, i) => (
<div
key={d}
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
}`}
>
{d}
</div>
))}
</div>
{/* Grid */}
<div className="relative grid grid-cols-7 grid-rows-6">
{cells.map((cell, idx) => {
const k = ymd(cell.date);
const dayStops = stopsByDate.get(k) ?? [];
const visibleStops = visibleStopsByDate.get(k) ?? [];
const isToday = isSameDay(cell.date, today);
const isSelected = k === selectedDate;
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
const overflow = dayStops.length - visibleStops.length;
return (
<button
key={k}
type="button"
onClick={() => setSelectedDate(k)}
className={`
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
`}
>
{/* Date number row */}
<div className="flex items-center justify-between">
<span
className={`
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
${isToday
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
: !cell.inMonth
? "text-[var(--admin-text-muted)]/50"
: isSelected
? "text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-primary)]"
}
`}
>
{cell.date.getDate()}
</span>
{dayStops.length > 0 && cell.inMonth && (
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{dayStops.length}
</span>
)}
</div>
{/* Stop chips */}
<div className="flex flex-col gap-0.5 overflow-hidden">
{visibleStops.slice(0, 3).map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<div
key={s.id}
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
<span className="truncate">
{s.time && (
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
)}
{s.city}
</span>
</div>
);
})}
{overflow > 0 && (
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
+{overflow} more
</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
const st = STATUS_STYLES[s];
return (
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
<span>{s}</span>
</div>
);
})}
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span>
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
{upcomingWeek}
</span>{" "}
upcoming in 7d
</span>
</div>
</footer>
</section>
{/* Day detail panel */}
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<div className="border-b border-[var(--admin-border)] px-5 py-4">
<div className="flex items-baseline gap-2">
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
: "Pick a day"}
</h3>
</div>
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "Select a date on the calendar"}
</p>
</div>
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
{!selectedDate ? (
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
No day selected
</div>
) : selectedDayStops.length === 0 ? (
<div className="px-4 py-10 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
Free day on the route
</p>
</div>
) : (
<ul className="space-y-1">
{selectedDayStops.map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
>
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
{formatTime12(s.time).split(" ")[0]}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{s.city}, {s.state}
</p>
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{formatTime12(s.time).split(" ")[1]}
</span>
</div>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
<div className="mt-1.5 flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
{status}
</span>
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
</span>
) : null}
</div>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
}
@@ -1,236 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { type Stop, type StopView, getStopStatus } from "./types";
import StopsCalendar from "./StopsCalendar";
import StopsLocations from "./StopsLocations";
import StopsList from "./StopsList";
type Props = {
stops: Stop[];
page?: number;
totalPages?: number;
totalCount?: number;
};
const TABS: { value: StopView; label: string; hint: string }[] = [
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
{ value: "list", label: "List", hint: "All stops in order" },
];
export default function StopsDashboardClient({ stops, page = 1, totalPages = 1, totalCount = 0 }: Props) {
const [view, setView] = useState<StopView>("calendar");
const stats = useMemo(() => {
const total = stops.length;
const active = stops.filter((s) => getStopStatus(s) === "active").length;
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
const venues = new Set(
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
).size;
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7 = new Date(today);
in7.setDate(today.getDate() + 7);
const upcoming = stops.filter((s) => {
if (!s.date) return false;
if (getStopStatus(s) === "inactive") return false;
const d = new Date(s.date + "T00:00:00");
return d >= today && d < in7;
}).length;
return { total, active, draft, cities, venues, upcoming };
}, [stops]);
return (
<div className="space-y-6">
{/* Top stats strip — almanac style */}
<section
aria-label="Stops overview"
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
Harvest Dispatch · Almanac
</p>
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
{stats.total === 0
? "No stops scheduled"
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
</span>
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
</p>
</div>
{/* Tab nav — binder-style with notched corners */}
<div className="flex items-end gap-1">
{TABS.map((t) => {
const isActive = t.value === view;
return (
<button
key={t.value}
type="button"
onClick={() => setView(t.value)}
aria-pressed={isActive}
className={`
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
${isActive
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
}
`}
>
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
{t.label}
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
{t.hint}
</span>
{isActive && (
<span
aria-hidden
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
/>
)}
</button>
);
})}
</div>
</div>
{/* Stat cells */}
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
<AlmanacStat
numeral="I"
label="Active"
value={stats.active}
accent
/>
<AlmanacStat
numeral="II"
label="Upcoming 7d"
value={stats.upcoming}
accent={stats.upcoming > 0}
/>
<AlmanacStat
numeral="III"
label="Cities"
value={stats.cities}
/>
<AlmanacStat
numeral="IV"
label="Venues"
value={stats.venues}
/>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="relative flex items-center justify-between border-t border-[var(--admin-border-light)] pt-4 mt-4">
<p className="font-mono text-xs text-[var(--admin-text-muted)]">
Showing {((page - 1) * 50) + 1}{Math.min(page * 50, totalCount)} of {totalCount}
</p>
<div className="flex items-center gap-1">
<a
href={page > 1 ? `?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
page <= 1
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
</a>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const p = i + 1;
return (
<a
key={p}
href={`?page=${p}`}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
p === page
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
{p}
</a>
);
})}
<a
href={page < totalPages ? `?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
page >= totalPages
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
</a>
</div>
</div>
)}
</section>
{/* Active view */}
<section>
{view === "calendar" && <StopsCalendar stops={stops} />}
{view === "locations" && <StopsLocations stops={stops} />}
{view === "list" && <StopsList stops={stops} />}
</section>
</div>
);
}
function AlmanacStat({
numeral,
label,
value,
accent,
}: {
numeral: string;
label: string;
value: number;
accent?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<span
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
{numeral}
</span>
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{label}
</p>
<p
className={`font-display text-2xl font-medium leading-none tabular-nums ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
"use client";
import { useMemo } from "react";
import { type Stop, getStopStatus } from "./types";
type Props = {
stops: Stop[];
};
// Minimal list view used inside the StopsDashboard tab nav.
// It is a calmer, read-only list; for bulk publish/edit use the
// dedicated /admin/stops page.
export default function StopsList({ stops }: Props) {
const sorted = useMemo(() => {
return stops.toSorted((a, b) => (a.date || "").localeCompare(b.date || ""));
}, [stops]);
if (sorted.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Switch to a different tab to add stops.
</p>
</div>
);
}
return (
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
{sorted.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
return (
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{s.time ? s.time.slice(0, 5) : "—"}
</span>
<span className="flex-1 min-w-0">
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
{s.city}, {s.state}
</span>
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
</span>
{brand && (
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{brand}
</span>
)}
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</li>
);
})}
</ul>
);
}
@@ -1,412 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type LocationGroup,
type Stop,
formatTime12,
getStopStatus,
} from "./types";
type Props = {
stops: Stop[];
};
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
const map = new Map<string, LocationGroup>();
for (const s of stops) {
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
let group = map.get(key);
if (!group) {
group = {
key,
city: s.city || "Unknown",
state: s.state || "",
venueCount: 0,
total: 0,
active: 0,
draft: 0,
inactive: 0,
upcoming: 0,
nextDate: null,
firstDate: null,
lastDate: null,
sampleVenue: s.location,
stops: [],
};
map.set(key, group);
}
group.stops.push(s);
group.total += 1;
const status = getStopStatus(s);
if (status === "active") group.active += 1;
else if (status === "draft") group.draft += 1;
else group.inactive += 1;
if (s.date) {
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
}
}
// post-process: count distinct venues, upcoming stops
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
for (const g of map.values()) {
const venues = new Set(
g.stops.flatMap((s) => {
const trimmed = (s.location || "").trim().toLowerCase();
return trimmed ? [trimmed] : [];
})
);
g.venueCount = Math.max(1, venues.size);
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
const future: string[] = [];
for (const s of g.stops) {
if (s.date >= todayStr && getStopStatus(s) !== "inactive") {
future.push(s.date);
}
}
future.sort();
g.nextDate = future[0] ?? null;
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}
return Array.from(map.values()).sort((a, b) => {
// active locations with upcoming stops first, sorted by next date; then by city
if (a.nextDate && !b.nextDate) return -1;
if (!a.nextDate && b.nextDate) return 1;
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
return a.city.localeCompare(b.city);
});
}
function formatDateLabel(ymd: string | null): string {
if (!ymd) return "—";
const d = new Date(ymd + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatRange(a: string | null, b: string | null): string {
if (!a) return "—";
if (!b || a === b) return formatDateLabel(a);
return `${formatDateLabel(a)}${formatDateLabel(b)}`;
}
export default function StopsLocations({ stops }: Props) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [showOnlyActive, setShowOnlyActive] = useState(false);
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return groups.filter((g) => {
if (q) {
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (showOnlyActive && g.active === 0) return false;
return true;
});
}, [groups, query, showOnlyActive]);
// Aggregated stats
const stats = useMemo(() => {
const totalLocations = groups.length;
const totalStops = stops.length;
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
}, [groups, stops]);
if (stops.length === 0) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
No locations yet
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Create your first stop to see pickup locations here.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Stats strip — almanac style */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{[
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
].map((s) => (
<div
key={s.numeral}
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
>
<div className="flex items-start justify-between">
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{s.numeral} · {s.label}
</p>
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
{s.value}
</p>
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{s.hint}
</p>
</div>
<div
aria-hidden
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
>
{s.numeral}
</div>
</div>
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
</div>
))}
</section>
{/* Filter bar */}
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
<div className="relative flex-1 min-w-[200px] max-w-md">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input aria-label="Search City, State, Venue..."
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search city, state, venue..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
/>
</div>
<div className="flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={showOnlyActive}
onClick={() => setShowOnlyActive((v) => !v)}
aria-label="Show only active locations"
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
Active only
</span>
</div>
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{filtered.length} of {groups.length} locations
</span>
</section>
{/* Location grid */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
</div>
) : (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((g, idx) => {
const isOpen = expandedKey === g.key;
return (
<article
key={g.key}
className={`
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
`}
>
{/* Decorative route number */}
<div
aria-hidden
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
>
{String(idx + 1).padStart(2, "0")}
</div>
<button
type="button"
onClick={() => setExpandedKey(isOpen ? null : g.key)}
className="block w-full text-left p-5"
aria-expanded={isOpen}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Pin marker */}
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
</svg>
</span>
<div className="min-w-0">
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
{g.city}
{g.state && (
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
{g.state}
</span>
)}
</h3>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
{g.sampleVenue}
</p>
</div>
</div>
</div>
<div
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</div>
</div>
{/* Stat grid */}
<div className="mt-5 grid grid-cols-3 gap-2">
<Stat label="Stops" value={g.total} />
<Stat label="Active" value={g.active} accent={g.active > 0} />
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
</div>
{/* Date range + drafts */}
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
<div className="text-[var(--admin-text-muted)]">
{g.firstDate ? (
<>
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
{" · "}
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
</>
) : (
"No dates"
)}
</div>
{g.draft > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
{g.draft} draft
</span>
)}
</div>
{/* Next up ribbon */}
{g.nextDate && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
Next
</span>
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
{formatDateLabel(g.nextDate)}
</span>
</div>
)}
</button>
{/* Expanded stop list */}
{isOpen && (
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
All stops · {g.stops.length}
</h4>
<Link
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
>
+ Add at this city
</Link>
</div>
<ul className="space-y-1">
{g.stops.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{formatTime12(s.time)}
</span>
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
{s.location}
</span>
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</Link>
</li>
);
})}
</ul>
</div>
)}
</article>
);
})}
</section>
)}
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div
className={`rounded-lg border px-2.5 py-2 ${
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
}`}
>
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
<p
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
export type StopStatus = "draft" | "active" | "inactive";
export type Stop = {
id: string;
city: string;
state: string;
date: string; // YYYY-MM-DD
time: string; // HH:MM
location: string;
active: boolean;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
cutoff_date?: string | null;
brands: { name: string } | { name: string }[];
};
export type StopView = "calendar" | "locations" | "list";
export type LocationGroup = {
key: string;
city: string;
state: string;
venueCount: number; // distinct location strings at this city
total: number;
active: number;
draft: number;
inactive: number;
upcoming: number;
nextDate: string | null; // YYYY-MM-DD
firstDate: string | null;
lastDate: string | null;
sampleVenue: string;
stops: Stop[];
};
export function getStopStatus(s: Stop): StopStatus {
if (s.status === "draft") return "draft";
return s.active ? "active" : "inactive";
}
export function getStopDate(s: Stop): Date | null {
if (!s.date) return null;
const d = new Date(s.date + "T00:00:00");
return isNaN(d.getTime()) ? null : d;
}
export function formatTime12(time: string): string {
if (!time) return "—";
const [h, m] = time.split(":");
const hour = parseInt(h, 10);
if (isNaN(hour)) return time;
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
}
export function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
-118
View File
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
interface ChangelogEntry {
id: string;
version: string;
date: string;
title: string;
description: string;
category: "feature" | "improvement" | "bugfix" | "security";
}
interface ChangelogFeedProps {
entries?: ChangelogEntry[];
}
const DEFAULT_ENTRIES: ChangelogEntry[] = [
{
id: "1",
version: "2.4.0",
date: "2025-01-15",
title: "Harvest Reach Email Campaigns",
description: "Send beautiful email campaigns to your customers with templates, scheduling, and analytics.",
category: "feature",
},
{
id: "2",
version: "2.3.0",
date: "2025-01-08",
title: "Square Inventory Sync",
description: "Two-way sync with Square POS for products and inventory.",
category: "feature",
},
{
id: "3",
version: "2.2.0",
date: "2024-12-10",
title: "AI Intelligence Pack",
description: "Campaign writer, pricing advisor, and demand forecasting powered by AI.",
category: "feature",
},
];
const categoryColors = {
feature: "bg-emerald-100 text-emerald-700",
improvement: "bg-blue-100 text-blue-700",
bugfix: "bg-amber-100 text-amber-700",
security: "bg-purple-100 text-purple-700",
};
const categoryLabels = {
feature: "New Feature",
improvement: "Improvement",
bugfix: "Bug Fix",
security: "Security",
};
export default function ChangelogFeed({ entries = DEFAULT_ENTRIES }: ChangelogFeedProps) {
const [readItems, setReadItems] = useState<Set<string>>(new Set());
const [showAll, setShowAll] = useState(false);
const displayedEntries = showAll ? entries : entries.slice(0, 5);
const markAsRead = (id: string) => {
setReadItems((prev) => new Set([...prev, id]));
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
};
return (
<div className="space-y-4">
{displayedEntries.map((entry) => {
const isRead = readItems.has(entry.id);
return (
<div
key={entry.id}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
markAsRead(entry.id);
}
}}
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md cursor-pointer ${
isRead ? "border-gray-200 opacity-70" : "border-emerald-200 shadow-sm"
}`}
onClick={() => markAsRead(entry.id)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded-md text-xs font-semibold ${categoryColors[entry.category]}`}>
{categoryLabels[entry.category]}
</span>
<span className="text-xs font-mono text-gray-400">v{entry.version}</span>
</div>
<time className="text-xs text-gray-400">{formatDate(entry.date)}</time>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{entry.title}</h3>
<p className="text-sm text-gray-600">{entry.description}</p>
</div>
);
})}
{entries.length > 5 && (
<button type="button"
onClick={() => setShowAll(!showAll)}
className="w-full py-3 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
>
{showAll ? "Show less" : `Show ${entries.length - 5} more updates`}
</button>
)}
</div>
);
}
-683
View File
@@ -1,683 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ReactElement } from "react";
interface Feature {
icon: ReactElement;
title: string;
description: string;
bgColor: string;
borderColor: string;
hoverBg: string;
}
interface Stat {
prefix: string;
number: number;
suffix: string;
label: string;
}
const features: Feature[] = [
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
title: "Product Catalogs",
description:
"Showcase your seasonal produce with rich media galleries, detailed specs, and real-time availability updates.",
bgColor: "#f0f5f1",
borderColor: "#6b8f71",
hoverBg: "#e8f0e9",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
),
title: "Route Planning",
description:
"Optimize delivery routes across your farm network with intelligent mapping and scheduling tools.",
bgColor: "#fdf6f0",
borderColor: "#c97a3e",
hoverBg: "#fbeeda",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
),
title: "Order Management",
description:
"Track orders from placement through delivery with automated status updates and seamless integrations.",
bgColor: "#f5f0f8",
borderColor: "#7a5c9e",
hoverBg: "#ece6f5",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3h18v18H3zM21 9H3M9 21V9" />
</svg>
),
title: "Wholesale Portal",
description:
"Give buyers a dedicated space to browse pricing, request quotes, and place bulk orders on their schedule.",
bgColor: "#f0f5f5",
borderColor: "#5c8a8f",
hoverBg: "#e5f0f2",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
),
title: "Harvest Reach",
description:
"Send email and SMS campaigns that keep buyers informed about seasonal availability and new harvests.",
bgColor: "#faf5f0",
borderColor: "#c97a3e",
hoverBg: "#f5ebe0",
},
{
icon: (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
),
title: "Smart Analytics",
description:
"Uncover sales trends, buyer behavior, and operational insights with real-time dashboards and reports.",
bgColor: "#f5f8f0",
borderColor: "#6b8f71",
hoverBg: "#eaf3e5",
},
];
const stats: Stat[] = [
{ prefix: "", number: 500, suffix: "+", label: "Produce Brands" },
{ prefix: "", number: 50000, suffix: "+", label: "Orders Delivered" },
{ prefix: "", number: 98, suffix: "%", label: "On-Time Delivery" },
{ prefix: "$", number: 2, suffix: "M+", label: "Weekly Sales" },
];
export default function FeaturesAndStats() {
const [countersStarted, setCountersStarted] = useState(false);
const [counters, setCounters] = useState<number[]>(() => stats.map(() => 0));
const statsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !countersStarted) {
setCountersStarted(true);
}
},
{ threshold: 0.3 }
);
if (statsRef.current) {
observer.observe(statsRef.current);
}
return () => observer.disconnect();
}, [countersStarted]);
useEffect(() => {
if (!countersStarted) return;
const duration = 2000;
const steps = 60;
const interval = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = step / steps;
const easeOut = 1 - Math.pow(1 - progress, 3);
setCounters(
stats.map((stat) => {
if (stat.number >= 1000) {
return Math.floor(stat.number * easeOut);
} else if (stat.number >= 100) {
return Math.floor(stat.number * easeOut);
} else if (stat.number >= 10) {
return Math.floor(stat.number * easeOut);
} else {
return Math.floor(stat.number * easeOut);
}
})
);
if (step >= steps) {
clearInterval(timer);
setCounters(stats.map((stat) => stat.number));
}
}, interval);
return () => clearInterval(timer);
}, [countersStarted]);
return (
<div
style={{
fontFamily: "var(--font-manrope)",
background: "#faf8f5",
color: "#1a1a1a",
minHeight: "100vh",
}}
>
<style dangerouslySetInnerHTML={{ __html: `
.section-label {
font-family: var(--font-manrope);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #c97a3e;
margin-bottom: 0.75rem;
}
.section-title {
font-family: var(--font-fraunces);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 600;
color: #1a4d2e;
line-height: 1.15;
margin-bottom: 1rem;
}
.section-subtitle {
font-family: var(--font-manrope);
font-size: 1.125rem;
color: #4a4a4a;
line-height: 1.6;
max-width: 540px;
}
.feature-card {
border-radius: 16px;
padding: 2rem;
border: 1px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: default;
opacity: 0;
transform: translateY(24px);
animation: fa-fade-in-up 0.6s ease forwards;
}
.feature-card:hover {
transform: translateY(-6px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
}
.feature-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
transition: transform 0.3s ease;
}
.feature-card:hover .feature-icon {
transform: scale(1.05);
}
.feature-title {
font-family: var(--font-fraunces);
font-size: 1.375rem;
font-weight: 600;
color: #1a4d2e;
margin-bottom: 0.625rem;
line-height: 1.3;
}
.feature-description {
font-size: 0.9375rem;
color: #555;
line-height: 1.65;
}
.stat-block {
text-align: center;
opacity: 0;
transform: translateY(20px);
animation: fa-fade-in-up 0.5s ease forwards;
}
.stat-number {
font-family: var(--font-fraunces);
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
color: #1a4d2e;
line-height: 1;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.9375rem;
color: #6b8f71;
font-weight: 500;
letter-spacing: 0.02em;
}
.stats-divider {
width: 1px;
background: linear-gradient(
to bottom,
transparent,
#c97a3e40,
transparent
);
align-self: stretch;
margin: 0.1rem 0;
}
.section-divider {
width: 48px;
height: 2px;
background: linear-gradient(
to right,
#c97a3e,
#1a4d2e
);
border-radius: 1px;
margin: 0;
}
@keyframes fa-fade-in-up {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fa-slide-in-left {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fa-slide-in-right {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fa-scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-left {
animation: fa-slide-in-left 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-right {
animation: fa-slide-in-right 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-scale {
animation: fa-scale-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
.stagger-6 { animation-delay: 0.6s; }
.stat-stagger-1 { animation-delay: 0.15s; }
.stat-stagger-2 { animation-delay: 0.3s; }
.stat-stagger-3 { animation-delay: 0.45s; }
.stat-stagger-4 { animation-delay: 0.6s; }
`}} />
{/* ─── FEATURES SECTION ─── */}
<section
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "6rem 1.5rem",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
marginBottom: "4rem",
}}
>
<div
className="animate-scale"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<span className="section-label" style={{ marginBottom: "0.75rem" }}>
Platform Features
</span>
<div className="section-divider" style={{ marginBottom: "1.25rem" }} />
<h2 className="section-title" style={{ marginBottom: "1rem" }}>
Everything You Need
</h2>
<p
className="section-subtitle"
style={{
maxWidth: "520px",
marginBottom: "0",
color: "#5a5a5a",
fontSize: "1.0625rem",
}}
>
From farm to table, manage your entire produce supply chain in one
place.
</p>
</div>
</div>
{/* Feature Cards Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: "1.25rem",
}}
>
{features.map((feature, index) => (
<div
key={feature.title}
className={`feature-card stagger-${index + 1}`}
style={{
backgroundColor: feature.bgColor,
borderColor: `rgba(${index % 2 === 0 ? "107,143,113" : "201,122,62"}, 0.15)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = feature.hoverBg;
e.currentTarget.style.borderColor = feature.borderColor;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = feature.bgColor;
e.currentTarget.style.borderColor = `rgba(${
index % 2 === 0 ? "107,143,113" : "201,122,62"
}, 0.15)`;
}}
>
<div
className="feature-icon"
style={{
background: `linear-gradient(135deg, ${feature.borderColor}18, ${feature.borderColor}10)`,
color: feature.borderColor,
border: `1px solid ${feature.borderColor}20`,
}}
>
{feature.icon}
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-description">{feature.description}</p>
</div>
))}
</div>
</section>
{/* ─── STATS SECTION ─── */}
<section
ref={statsRef}
style={{
background: "#1a4d2e",
padding: "5rem 1.5rem",
position: "relative",
overflow: "hidden",
}}
>
{/* Subtle background pattern */}
<div
style={{
position: "absolute",
inset: 0,
opacity: 0.04,
backgroundImage:
"radial-gradient(circle at 25% 25%, #faf8f5 1px, transparent 1px), radial-gradient(circle at 75% 75%, #faf8f5 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
{/* Decorative top line */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "3px",
background:
"linear-gradient(to right, #c97a3e, #faf8f5, #c97a3e)",
}}
/>
<div
style={{
maxWidth: "1000px",
margin: "0 auto",
position: "relative",
zIndex: 1,
}}
>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: "3.5rem" }}>
<span
style={{
display: "block",
fontFamily: "var(--font-manrope)",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "#c97a3e",
marginBottom: "0.75rem",
}}
>
Our Reach
</span>
<h2
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2rem, 4vw, 2.75rem)",
fontWeight: 600,
color: "#faf8f5",
lineHeight: 1.2,
margin: "0 0 0.75rem",
}}
>
Growing Together
</h2>
<div
style={{
width: "48px",
height: "2px",
background: "linear-gradient(to right, transparent, #c97a3e, transparent)",
borderRadius: "1px",
margin: "0 auto",
}}
/>
</div>
{/* Stats Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
alignItems: "center",
}}
>
{stats.map((stat, index) => (
<div
key={stat.label}
className={`stat-block stat-stagger-${index + 1}`}
>
<div
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2.25rem, 4.5vw, 3.75rem)",
fontWeight: 700,
color: "#faf8f5",
lineHeight: 1,
marginBottom: "0.5rem",
display: "flex",
alignItems: "baseline",
justifyContent: "center",
gap: "0.1em",
}}
>
{stat.prefix && (
<span
style={{
fontSize: "0.55em",
fontWeight: 500,
color: "#c97a3e",
marginRight: "0.05em",
}}
>
{stat.prefix}
</span>
)}
<span>
{counters[index].toLocaleString()}
</span>
<span
style={{
fontSize: "0.55em",
fontWeight: 500,
color: "#c97a3e",
marginLeft: "0.05em",
}}
>
{stat.suffix}
</span>
</div>
<p
style={{
fontFamily: "var(--font-manrope)",
fontSize: "0.875rem",
fontWeight: 500,
color: "#6b8f71",
letterSpacing: "0.02em",
margin: 0,
}}
>
{stat.label}
</p>
{/* Divider between stats */}
{index < stats.length - 1 && (
<div
style={{
display: "none",
}}
className="stats-divider"
/>
)}
</div>
))}
</div>
</div>
</section>
</div>
);
}
@@ -1,670 +0,0 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
const testimonials = [
{
initials: "TC",
name: "Tuxedo Corn Co.",
location: "Olathe, Colorado",
quote:
"Route Commerce transformed how we manage our wholesale operations. Our pickup efficiency increased by 40% in the first month.",
},
{
initials: "IR",
name: "Indian River Citrus",
location: "Indian River, Florida",
quote:
"The Harvest Reach feature alone has recovered thousands in abandoned cart revenue. Game changer for our business.",
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: "easeOut" as const,
},
},
};
const cardHoverVariants = {
rest: { y: 0, boxShadow: "0 4px 24px rgba(26, 77, 46, 0.08)" },
hover: {
y: -4,
boxShadow: "0 12px 40px rgba(26, 77, 46, 0.12)",
transition: { duration: 0.3, ease: "easeOut" },
},
};
export default function TestimonialsAndCTA() {
return (
<>
{/* ─── Testimonials Section ─────────────────────────────────────────────── */}
<section
style={{
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
fontFamily: "var(--font-manrope)",
}}
>
{/* Decorative botanical accent */}
<div
style={{
position: "absolute",
left: "5%",
top: "20%",
width: "120px",
height: "120px",
opacity: 0.06,
background: "radial-gradient(ellipse at center, #1a4d2e 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div
style={{
position: "absolute",
right: "8%",
bottom: "15%",
width: "80px",
height: "80px",
opacity: 0.05,
background: "radial-gradient(ellipse at center, #c97a3e 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div className="relative mx-auto max-w-6xl px-6 py-24">
{/* Section header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.7, ease: [0.22, 0.61, 0.36, 1] }}
className="mb-16 text-center"
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#6b8f71",
marginBottom: "12px",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Testimonials
</span>
<h2
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2.5rem, 5vw, 3.5rem)",
fontWeight: 600,
lineHeight: 1.1,
color: "#1a1a1a",
letterSpacing: "-0.02em",
}}
>
Trusted by Farms
</h2>
<p
style={{
marginTop: "16px",
fontSize: "1.125rem",
color: "#555555",
maxWidth: "480px",
marginLeft: "auto",
marginRight: "auto",
}}
>
Produce operations across the country rely on Route Commerce to power their farm-fresh distribution.
</p>
</motion.div>
{/* Testimonial cards */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-40px" }}
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
gap: "24px",
maxWidth: "800px",
margin: "0 auto",
}}
>
{testimonials.map((testimonial) => (
<motion.div
key={testimonial.name}
variants={itemVariants}
style={{
background: "linear-gradient(145deg, #ffffff 0%, #fdfcfa 100%)",
borderRadius: "20px",
padding: "32px",
border: "1px solid rgba(26, 77, 46, 0.08)",
position: "relative",
overflow: "hidden",
}}
whileHover="hover"
initial="rest"
animate="rest"
>
{/* Decorative quote mark */}
<div
style={{
position: "absolute",
top: "-8px",
right: "20px",
fontSize: "80px",
fontFamily: "var(--font-fraunces)",
color: "rgba(26, 77, 46, 0.06)",
lineHeight: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
&ldquo;
</div>
{/* Avatar with gradient */}
<div
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
background: `linear-gradient(135deg, #1a4d2e 0%, #6b8f71 50%, #c97a3e 100%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
boxShadow: "0 4px 12px rgba(26, 77, 46, 0.15)",
}}
>
<span
style={{
color: "#ffffff",
fontSize: "1rem",
fontWeight: 700,
letterSpacing: "0.05em",
textShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
>
{testimonial.initials}
</span>
</div>
{/* Quote text */}
<blockquote
style={{
fontSize: "1rem",
lineHeight: 1.7,
color: "#1a1a1a",
margin: 0,
marginBottom: "20px",
position: "relative",
zIndex: 1,
}}
>
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
{/* Attribution */}
<div style={{ borderTop: "1px solid rgba(0,0,0,0.06)", paddingTop: "16px" }}>
<p
style={{
fontWeight: 700,
fontSize: "0.9375rem",
color: "#1a1a1a",
margin: 0,
}}
>
{testimonial.name}
</p>
<p
style={{
fontSize: "0.8125rem",
color: "#6b8f71",
margin: 0,
marginTop: "4px",
fontWeight: 500,
}}
>
{testimonial.location}
</p>
</div>
</motion.div>
))}
</motion.div>
{/* Trust indicators */}
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
style={{
display: "flex",
justifyContent: "center",
gap: "40px",
marginTop: "48px",
flexWrap: "wrap",
}}
>
{[
{ value: "200+", label: "Farms & Co-ops" },
{ value: "40%", label: "Avg. Efficiency Gain" },
{ value: "94%", label: "Customer Retention" },
].map((stat) => (
<div key={stat.label} style={{ textAlign: "center" }}>
<div
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "2.5rem",
fontWeight: 600,
color: "#1a4d2e",
lineHeight: 1,
}}
>
{stat.value}
</div>
<div
style={{
fontSize: "0.8125rem",
color: "#6b8f71",
marginTop: "6px",
fontWeight: 500,
}}
>
{stat.label}
</div>
</div>
))}
</motion.div>
</div>
</section>
{/* ─── CTA Section ──────────────────────────────────────────────────────── */}
<section
style={{
background: "linear-gradient(180deg, #f5f2ed 0%, #faf8f5 50%, #faf8f5 100%)",
position: "relative",
overflow: "hidden",
fontFamily: "var(--font-manrope)",
}}
>
{/* Decorative gradient orbs */}
<div
style={{
position: "absolute",
top: "10%",
left: "10%",
width: "300px",
height: "300px",
background: "radial-gradient(circle, rgba(26, 77, 46, 0.08) 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div
style={{
position: "absolute",
bottom: "0",
right: "5%",
width: "250px",
height: "250px",
background: "radial-gradient(circle, rgba(201, 122, 62, 0.06) 0%, transparent 70%)",
borderRadius: "50%",
pointerEvents: "none",
}}
aria-hidden="true"
/>
{/* Botanical line decoration */}
<svg
style={{
position: "absolute",
top: "20px",
right: "15%",
width: "60px",
height: "100px",
opacity: 0.15,
}}
viewBox="0 0 60 100"
fill="none"
aria-hidden="true"
>
<path
d="M30 100 C30 60, 10 50, 30 20 C50 50, 30 60, 30 100"
stroke="#1a4d2e"
strokeWidth="2"
fill="none"
/>
<path
d="M30 70 C20 60, 15 55, 25 45"
stroke="#1a4d2e"
strokeWidth="1.5"
fill="none"
/>
<path
d="M30 70 C40 60, 45 55, 35 45"
stroke="#1a4d2e"
strokeWidth="1.5"
fill="none"
/>
<circle cx="30" cy="15" r="3" fill="#6b8f71" />
<circle cx="22" cy="42" r="2" fill="#c97a3e" />
<circle cx="38" cy="42" r="2" fill="#c97a3e" />
</svg>
<div className="relative mx-auto max-w-4xl px-6 py-28 text-center">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
>
{/* Section label */}
<span
style={{
display: "inline-block",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#c97a3e",
marginBottom: "16px",
padding: "6px 14px",
background: "rgba(201, 122, 62, 0.1)",
borderRadius: "20px",
}}
>
Get Started Today
</span>
{/* Headline */}
<h2
style={{
fontFamily: "var(--font-fraunces)",
fontSize: "clamp(2.75rem, 6vw, 4rem)",
fontWeight: 600,
lineHeight: 1.1,
color: "#1a1a1a",
letterSpacing: "-0.02em",
marginBottom: "20px",
}}
>
Ready to Grow Your Routes?
</h2>
{/* Subtitle */}
<p
style={{
fontSize: "1.125rem",
lineHeight: 1.7,
color: "#555555",
maxWidth: "560px",
margin: "0 auto 36px",
}}
>
Join hundreds of produce brands who trust Route Commerce to power their farm-fresh operations.
</p>
{/* CTA Buttons */}
<div
style={{
display: "flex",
gap: "16px",
justifyContent: "center",
flexWrap: "wrap",
}}
>
{/* Primary button */}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Link
href="/admin"
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "16px 32px",
background: "rgba(26, 77, 46, 0.9)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.25)",
color: "#ffffff",
fontSize: "1rem",
fontWeight: 600,
borderRadius: "12px",
cursor: "pointer",
textDecoration: "none",
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.1)",
transition: "background 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease, border-color 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(45, 107, 79, 0.95)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(26, 77, 46, 0.45), inset 0 1px 0 rgba(255,255,255, 0.2)";
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.borderColor = "rgba(255,255,255, 0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(26, 77, 46, 0.9)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255,255,255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.1)";
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.borderColor = "rgba(255,255,255, 0.25)";
}}
>
Start Free Trial
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</motion.div>
{/* Secondary button */}
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Link
href="/brands"
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "16px 32px",
background: "rgba(250, 248, 245, 0.8)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(26, 77, 46, 0.2)",
color: "#1a4d2e",
fontSize: "1rem",
fontWeight: 600,
borderRadius: "12px",
cursor: "pointer",
textDecoration: "none",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)",
transition: "background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.9)";
e.currentTarget.style.borderColor = "rgba(26, 77, 46, 0.4)";
e.currentTarget.style.boxShadow = "0 8px 24px rgba(26, 77, 46, 0.12), inset 0 1px 0 rgba(255,255,255, 0.6)";
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(250, 248, 245, 0.8)";
e.currentTarget.style.borderColor = "rgba(26, 77, 46, 0.2)";
e.currentTarget.style.boxShadow = "0 4px 16px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)";
e.currentTarget.style.transform = "translateY(0)";
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
View Farms
</Link>
</motion.div>
</div>
{/* Disclaimer */}
<p
style={{
marginTop: "24px",
fontSize: "0.8125rem",
color: "#888888",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.6 }}
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
No credit card required · 14-day free trial · Cancel anytime
</p>
</motion.div>
{/* Bottom decorative element */}
<div
style={{
marginTop: "60px",
display: "flex",
justifyContent: "center",
gap: "8px",
opacity: 0.3,
}}
>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
width: i === 2 ? "24px" : "6px",
height: "6px",
borderRadius: "3px",
background: i === 2 ? "#1a4d2e" : "#1a4d2e",
opacity: i === 2 ? 1 : 0.4,
}}
/>
))}
</div>
</div>
</section>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes tc-fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tc-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes tc-pulse-soft {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Scroll-triggered animations */
section {
scroll-margin-top: 80px;
}
/* Ensure smooth transitions */
a, button {
-webkit-tap-highlight-color: transparent;
}
`}} />
</>
);
}
@@ -1,219 +0,0 @@
// In-App Notification Center - Bell icon with dropdown panel
"use client";
import Link from "next/link";
import { useState, useEffect, useRef, useCallback } from "react";
interface Notification {
id: string;
title: string;
message: string;
type: "info" | "success" | "warning" | "error";
read: boolean;
created_at: string;
link?: string;
}
interface NotificationCenterProps {
brandId?: string;
userId?: string;
}
export default function NotificationCenter({ brandId, userId }: NotificationCenterProps) {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
// Define fetchNotifications before useEffect
const fetchNotifications = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/notifications?brand_id=${brandId}`);
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications || []);
}
} catch (error) {
console.error("Failed to fetch notifications:", error);
} finally {
setLoading(false);
}
}, [brandId]);
// Fetch notifications when opened — handled in the click handler so
// we don't need a useEffect to watch `isOpen` and re-fetch on every
// open transition. See handleToggleOpen below.
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const markAsRead = async (id: string) => {
try {
await fetch("/api/notifications", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, read: true }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
} catch (error) {
console.error("Failed to mark as read:", error);
}
};
const markAllAsRead = async () => {
try {
await fetch("/api/notifications/mark-all-read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand_id: brandId }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
} catch (error) {
console.error("Failed to mark all as read:", error);
}
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
const getTypeStyles = (type: string) => {
switch (type) {
case "success":
return "bg-emerald-100 text-emerald-600";
case "warning":
return "bg-amber-100 text-amber-600";
case "error":
return "bg-red-100 text-red-600";
default:
return "bg-blue-100 text-blue-600";
}
};
const handleToggleOpen = useCallback(async () => {
const next = !isOpen;
setIsOpen(next);
if (next && brandId) {
await fetchNotifications();
}
}, [isOpen, brandId, fetchNotifications]);
return (
<div className="relative" ref={dropdownRef}>
{/* Bell Icon Button */}
<button type="button"
onClick={handleToggleOpen}
className="relative p-2 hover:bg-gray-100 rounded-xl transition-colors"
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ""}`}
>
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button type="button"
onClick={markAllAsRead}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
Mark all as read
</button>
)}
</div>
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-8 text-center">
<div className="w-6 h-6 border-2 border-gray-300 border-t-[#1a4d2e] rounded-full animate-spin mx-auto" />
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-sm text-gray-500">No notifications yet</p>
</div>
) : (
<div className="divide-y divide-gray-50">
{notifications.map((notification) => (
<div
key={notification.id}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (!notification.read) markAsRead(notification.id);
if (notification.link) window.location.href = notification.link;
}
}}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
!notification.read ? "bg-emerald-50/50" : ""
}`}
onClick={() => {
if (!notification.read) markAsRead(notification.id);
if (notification.link) window.location.href = notification.link;
}}
>
<div className="flex items-start gap-3">
<span className={`shrink-0 w-2 h-2 mt-2 rounded-full ${getTypeStyles(notification.type)} ${notification.read ? "opacity-30" : ""}`} />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 text-sm">{notification.title}</p>
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{notification.message}</p>
<p className="text-xs text-gray-400 mt-2">{formatTime(notification.created_at)}</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3">
<Link href="/admin/notifications" className="text-sm text-emerald-600 hover:text-emerald-700 font-medium">
View all notifications
</Link>
</div>
</div>
)}
</div>
);
}
@@ -1,316 +0,0 @@
// Onboarding Flow - Welcome tour and interactive demo
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { analytics } from "@/lib/analytics";
interface OnboardingStep {
id: string;
title: string;
description: string;
icon: React.ReactNode;
action?: string;
}
const STEPS: OnboardingStep[] = [
{
id: "welcome",
title: "Welcome to Route Commerce",
description: "The all-in-one platform for fresh produce wholesale distribution. Let's take a quick tour to get you started.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
id: "products",
title: "Manage Your Products",
description: "Add your fresh produce with pricing, categories, and images. Perfect for showcasing your farm's best offerings.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
action: "Add First Product",
},
{
id: "stops",
title: "Schedule Pickup Stops",
description: "Create scheduled stops for customers to pick up their orders. Manage locations, times, and capacity.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
action: "Create First Stop",
},
{
id: "orders",
title: "Track Orders",
description: "Monitor customer orders in real-time. Handle pickups and shipments with ease.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
},
{
id: "communications",
title: "Harvest Reach",
description: "Send beautiful email campaigns to your customers. Keep them informed about seasonal offerings and promotions.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
{
id: "complete",
title: "You're All Set!",
description: "Your dashboard is ready. Start adding products and scheduling stops to grow your wholesale business.",
icon: (
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
];
interface OnboardingProps {
brandId: string;
userId: string;
onComplete?: () => void;
}
export function OnboardingFlow({ brandId: _brandId, userId: _userId, onComplete }: OnboardingProps) {
const [currentStep, setCurrentStep] = useState(0);
const [isCompleted, setIsCompleted] = useState(false);
const router = useRouter();
useEffect(() => {
// Track onboarding start
analytics.onboardingStep("start", false);
}, []);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
const step = STEPS[currentStep];
analytics.onboardingStep(step.id, false);
setCurrentStep((prev) => prev + 1);
}
};
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1);
}
};
const handleSkip = () => {
analytics.onboardingStep("skipped", true);
setIsCompleted(true);
onComplete?.();
};
const handleComplete = () => {
analytics.onboardingStep("complete", true);
setIsCompleted(true);
onComplete?.();
};
const handleAction = () => {
const step = STEPS[currentStep];
analytics.buttonClicked(step.action || "next", "onboarding");
if (step.id === "products") {
router.push("/admin/products/new");
} else if (step.id === "stops") {
router.push("/admin/stops/new");
} else {
handleNext();
}
};
if (isCompleted) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="bg-white rounded-2xl p-8 max-w-md text-center"
>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete!</h2>
<p className="text-gray-600 mb-6">
You&apos;re ready to start growing your wholesale business.
</p>
<button type="button"
onClick={() => router.push("/admin")}
className="w-full px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
Go to Dashboard
</button>
</motion.div>
</div>
);
}
const step = STEPS[currentStep];
const progress = ((currentStep + 1) / STEPS.length) * 100;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden"
>
{/* Progress bar */}
<div className="h-1 bg-gray-200">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<div className="p-8">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{STEPS.map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
i <= currentStep ? "bg-primary" : "bg-gray-300"
}`}
/>
))}
</div>
{/* Icon */}
<div className="w-20 h-20 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-6 text-primary">
{step.icon}
</div>
{/* Content */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">{step.title}</h2>
<p className="text-gray-600">{step.description}</p>
</div>
{/* Actions */}
<div className="flex gap-3">
{currentStep > 0 ? (
<button type="button"
onClick={handlePrevious}
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
>
Back
</button>
) : (
<button type="button"
onClick={handleSkip}
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
>
Skip Tour
</button>
)}
{step.action ? (
<button type="button"
onClick={handleAction}
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
{step.action}
</button>
) : currentStep === STEPS.length - 1 ? (
<button type="button"
onClick={handleComplete}
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
Get Started
</button>
) : (
<button type="button"
onClick={handleNext}
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
>
Next
</button>
)}
</div>
</div>
{/* Step counter */}
<div className="bg-gray-50 px-8 py-4 text-center text-sm text-gray-500">
Step {currentStep + 1} of {STEPS.length}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
// Interactive demo component
export function InteractiveDemo({ onClose }: { onClose: () => void }) {
const [demoStep, setDemoStep] = useState(0);
const demoSteps = [
{ title: "Add Products", description: "Create your product catalog" },
{ title: "Schedule Stops", description: "Set up pickup locations" },
{ title: "Send Campaigns", description: "Email your customers" },
];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<h3 className="text-lg font-bold mb-4">Interactive Demo</h3>
<div className="space-y-4">
{demoSteps.map((step, i) => (
<div
key={step.title}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setDemoStep(i);
}}
className={`p-4 rounded-lg border-2 cursor-pointer transition-colors ${
i === demoStep ? "border-primary bg-primary/5" : "border-gray-200"
}`}
onClick={() => setDemoStep(i)}
>
<div className="font-medium">{step.title}</div>
<div className="text-sm text-gray-500">{step.description}</div>
</div>
))}
</div>
<div className="mt-6 flex gap-3">
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 border rounded-lg">
Close
</button>
<button type="button" className="flex-1 px-4 py-2 bg-primary text-white rounded-lg">
Start Demo
</button>
</div>
</div>
</div>
);
}
@@ -1,15 +0,0 @@
// Analytics Provider Component
"use client";
import { useEffect } from "react";
export default function AnalyticsProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Track initial page view
if (typeof window !== "undefined") {
console.log("[Analytics] Page loaded:", window.location.pathname);
}
}, []);
return <>{children}</>;
}
@@ -1,58 +0,0 @@
// Error Boundary Component
"use client";
import { Component, type ReactNode, type ErrorInfo } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Error boundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center max-w-md">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Something went wrong</h2>
<p className="text-gray-600 mb-4">We&apos;re sorry for the inconvenience. Please try refreshing the page.</p>
<button type="button"
onClick={() => window.location.reload()}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-500 transition-colors"
>
Refresh Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
-199
View File
@@ -1,199 +0,0 @@
// Referral System - Generate, share, and track referral codes
"use client";
import { useState } from "react";
import { analytics } from "@/lib/analytics";
interface ReferralCode {
code: string;
reward_type: "percentage" | "fixed";
reward_value: number;
uses: number;
max_uses: number;
created_at: string;
}
interface ReferralSystemProps {
brandId: string;
userId: string;
}
export function ReferralSystem({ brandId, userId }: ReferralSystemProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [selectedCode] = useState<ReferralCode | null>(null);
const generateCode = async () => {
setIsGenerating(true);
// Track event
analytics.featureUsed("referral_generate_code", { brand_id: brandId });
try {
const res = await fetch("/api/referrals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "generate",
brand_id: brandId,
user_id: userId,
}),
});
if (res.ok) {
// Code generated successfully - refresh list
analytics.referralShared("", "platform_select");
}
} catch (error) {
console.error("Failed to generate referral code:", error);
} finally {
setIsGenerating(false);
}
};
const shareCode = (code: ReferralCode, platform: string) => {
const shareUrl = `${window.location.origin}/register?ref=${code.code}`;
const text = `Join Route Commerce and get ${code.reward_value}% off your first month!`;
if (platform === "copy") {
navigator.clipboard.writeText(shareUrl);
analytics.referralShared(code.code, "copy_link");
} else if (platform === "email") {
window.location.href = `mailto:?subject=Try Route Commerce&body=${encodeURIComponent(text + "\n\n" + shareUrl)}`;
analytics.referralShared(code.code, "email");
} else if (platform === "twitter") {
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text + " " + shareUrl)}`, "_blank");
analytics.referralShared(code.code, "twitter");
} else if (platform === "facebook") {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, "_blank");
analytics.referralShared(code.code, "facebook");
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl p-6">
<div className="flex items-start justify-between">
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Refer & Earn</h3>
<p className="text-gray-600">
Share Route Commerce with other farms and earn rewards for each successful signup.
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">20%</div>
<div className="text-sm text-gray-500">Reward</div>
</div>
</div>
<button type="button"
onClick={generateCode}
disabled={isGenerating}
className="mt-4 w-full px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50"
>
{isGenerating ? "Generating..." : "Generate New Referral Code"}
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
<div className="text-2xl font-bold text-gray-900">0</div>
<div className="text-sm text-gray-500">Total Referrals</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
<div className="text-2xl font-bold text-green-600">0</div>
<div className="text-sm text-gray-500">Conversions</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
<div className="text-2xl font-bold text-gray-900">$0</div>
<div className="text-sm text-gray-500">Earned</div>
</div>
</div>
{/* How it works */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h4 className="font-semibold text-gray-900 mb-4">How It Works</h4>
<div className="space-y-4">
{[
{ step: "1", title: "Generate Code", desc: "Create your unique referral code" },
{ step: "2", title: "Share", desc: "Send to other farms via email or social" },
{ step: "3", title: "They Sign Up", desc: "Friend uses your code to register" },
{ step: "4", title: "Both Earn", desc: "Get 20% off, they get 20% off" },
].map((item) => (
<div key={item.title} className="flex items-start gap-4">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-primary font-bold">
{item.step}
</div>
<div>
<div className="font-medium text-gray-900">{item.title}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Share Modal */}
{shareModalOpen && selectedCode && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-md w-full p-6">
<h3 className="text-lg font-bold mb-4">Share Your Code</h3>
<div className="bg-gray-100 rounded-lg p-4 mb-4">
<code className="text-xl font-mono font-bold">{selectedCode.code}</code>
</div>
<div className="space-y-2">
<button type="button"
onClick={() => { shareCode(selectedCode, "copy"); setShareModalOpen(false); }}
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy Link
</button>
<button type="button"
onClick={() => { shareCode(selectedCode, "email"); setShareModalOpen(false); }}
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Email
</button>
<button type="button"
onClick={() => { shareCode(selectedCode, "twitter"); setShareModalOpen(false); }}
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
</svg>
Twitter
</button>
</div>
<button type="button"
onClick={() => setShareModalOpen(false)}
className="mt-4 w-full px-4 py-2 border border-gray-300 rounded-lg"
>
Close
</button>
</div>
</div>
)}
</div>
);
}
// Referral badge component for display
export function ReferralBadge({ code }: { code: string }) {
return (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<span>Ref: {code}</span>
</div>
);
}
@@ -1,310 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createHarvestLot } from "@/actions/route-trace/lots";
// One-color outline icons
const Icons = {
plant: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22V12M12 12C12 12 7 10 7 5c0-2.5 2.5-5 5-5s5 2.5 5 5c0 5-5 7-5 7z" />
</svg>
),
chevronUp: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m18 15-6-6-6 6"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
};
export default function LotCreateForm({ brandId }: { brandId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [notesOpen, setNotesOpen] = useState(false);
const [crop_type, setCropType] = useState("");
const [variety, setVariety] = useState("");
const [harvest_date, setHarvestDate] = useState(new Date().toISOString().split("T")[0]);
const [field_location, setFieldLocation] = useState("");
const [field_block, setFieldBlock] = useState("");
const [worker_name, setWorkerName] = useState("");
const [packer_name, setPackerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
const [yield_estimate_lbs, setYieldEstimateLbs] = useState("");
const [yield_unit, setYieldUnit] = useState("lbs");
const [customYieldUnit, setCustomYieldUnit] = useState("");
const [bin_id, setBinId] = useState("");
const [container_id, setContainerId] = useState("");
const [pallets, setPallets] = useState("");
const [notes, setNotes] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type,
variety: variety || undefined,
harvest_date,
field_location: field_location || undefined,
worker_name: worker_name || undefined,
packer_name: packer_name || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
notes: notes || undefined,
bin_id: bin_id || undefined,
container_id: container_id || undefined,
field_block: field_block || undefined,
yield_estimate_lbs: yield_estimate_lbs ? Number(yield_estimate_lbs) : undefined,
yield_unit: yield_unit === "custom" ? customYieldUnit.trim() || undefined : yield_unit,
pallets: pallets ? Number(pallets) : undefined,
});
if (result.success && result.lot) {
router.push(`/admin/route-trace/lots/${result.lot.id}`);
} else {
setError(result.error ?? "Failed to create lot");
}
});
}
return (
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-green-100">
<span className="text-green-600">{Icons.plant("h-5 w-5")}</span>
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">New Harvest Lot</h2>
<p className="text-sm text-stone-500">Quick entry scan or fill in the fields below</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-700">{error}</div>
)}
{/* Required — large touch targets for field use */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-crop-type" className="block text-sm font-semibold text-stone-800 mb-2">Crop Type *</label>
<input id="fld-1-crop-type" aria-label=". Sweet Corn"
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-2-variety" className="block text-sm font-semibold text-stone-800 mb-2">Variety</label>
<input id="fld-2-variety" aria-label=". Golden Bantam"
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
placeholder="e.g. Golden Bantam"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
<div>
<label htmlFor="fld-3-harvest-date" className="block text-sm font-semibold text-stone-800 mb-2">Harvest Date *</label>
<input id="fld-3-harvest-date" aria-label="Date"
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-4 text-lg font-medium outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
{/* Field & Location */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Field &amp; Location</p>
<div className="space-y-3">
<div>
<label htmlFor="fld-4-field-location" className="block text-xs font-medium text-stone-600 mb-1.5">Field / Location *</label>
<input id="fld-4-field-location" aria-label=". North Field"
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
placeholder="e.g. North Field"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-5-field-block" className="block text-xs font-medium text-stone-600 mb-1.5">Field Block</label>
<input id="fld-5-field-block" aria-label=". Block A"
type="text"
value={field_block}
onChange={(e) => setFieldBlock(e.target.value)}
placeholder="e.g. Block A"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-6-worker-harvest-lead" className="block text-xs font-medium text-stone-600 mb-1.5">Worker / Harvest Lead</label>
<input id="fld-6-worker-harvest-lead" aria-label=". Maria S."
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
placeholder="e.g. Maria S."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
</div>
{/* Quantity + Yield */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Weight &amp; Yield</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-7-actual-qty" className="block text-xs font-medium text-stone-600 mb-1.5">Actual Qty</label>
<input id="fld-7-actual-qty" aria-label=". 4800"
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
placeholder="e.g. 4800"
min="0"
step="0.01"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3.5 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1.5" htmlFor="fld-yield-est">Yield Est.</label>
<div className="flex rounded-xl border border-stone-200 bg-stone-50 overflow-hidden focus-within:border-emerald-600 focus-within:bg-white transition-colors">
<input id="fld-yield-est" aria-label=". 5000"
type="number"
value={yield_estimate_lbs}
onChange={(e) => setYieldEstimateLbs(e.target.value)}
placeholder="e.g. 5000"
min="0"
step="0.01"
className="flex-1 px-3 py-3.5 text-base outline-none bg-transparent"
/>
<select aria-label="Select"
value={yield_unit}
onChange={(e) => setYieldUnit(e.target.value)}
className="px-2 py-3.5 text-sm font-semibold text-stone-600 bg-stone-100 outline-none border-l border-stone-200 cursor-pointer"
>
<option value="lbs">lbs</option>
<option value="bushel">bu</option>
<option value="box">bx</option>
<option value="sack">sk</option>
<option value="crate">cr</option>
<option value="bin">bn</option>
<option value="pallet">plt</option>
<option value="custom">custom</option>
</select>
</div>
{yield_unit === "custom" && (
<input aria-label="Unit Name…"
type="text"
placeholder="Unit name…"
value={customYieldUnit}
onChange={(e) => setCustomYieldUnit(e.target.value)}
className="mt-1.5 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
)}
</div>
</div>
</div>
{/* Bin + Container */}
<div className="border-t border-stone-100 pt-5">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3">Bin, Container &amp; Pallets</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-8-bin-id" className="block text-xs font-medium text-stone-600 mb-1.5">Bin ID</label>
<input id="fld-8-bin-id" aria-label=". BIN 001"
type="text"
value={bin_id}
onChange={(e) => setBinId(e.target.value)}
placeholder="e.g. BIN-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-9-container-id" className="block text-xs font-medium text-stone-600 mb-1.5">Container ID</label>
<input id="fld-9-container-id" aria-label=". CONT A42"
type="text"
value={container_id}
onChange={(e) => setContainerId(e.target.value)}
placeholder="e.g. CONT-A42"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label htmlFor="fld-10-pallets" className="block text-xs font-medium text-stone-600 mb-1.5">Pallets</label>
<input id="fld-10-pallets" aria-label=". 4"
type="number"
value={pallets}
onChange={(e) => setPallets(e.target.value)}
placeholder="e.g. 4"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<div>
<label htmlFor="fld-11-packer-name" className="block text-xs font-medium text-stone-600 mb-1.5">Packer Name</label>
<input id="fld-11-packer-name" aria-label=". Jose R."
type="text"
value={packer_name}
onChange={(e) => setPackerName(e.target.value)}
placeholder="e.g. Jose R."
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
</div>
</div>
{/* Notes — collapsible */}
<div className="border-t border-stone-100 pt-5">
<button
type="button"
onClick={() => setNotesOpen(!notesOpen)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-stone-400 hover:text-stone-600"
>
<span>Notes</span>
<span className="text-stone-500">{notesOpen ? Icons.chevronUp("h-4 w-4") : Icons.chevronDown("h-4 w-4")}</span>
</button>
{notesOpen && (
<textarea aria-label="Spray Records, Weather Conditions, Or Other Harvest Details..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Spray records, weather conditions, or other harvest details..."
rows={3}
className="mt-3 w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600 focus:bg-white transition-colors resize-none"
/>
)}
</div>
{/* Save — big button */}
<div className="flex items-center justify-end border-t border-stone-100 pt-5">
<button
type="submit"
disabled={isPending || !crop_type || !harvest_date || !field_location}
className="rounded-xl bg-green-600 px-8 py-4 text-base font-bold text-white hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm"
>
{isPending ? "Creating..." : <><span className="inline-flex items-center gap-1.5">{Icons.plant("h-4 w-4")} Create Lot</span></>}
</button>
</div>
</form>
</div>
);
}
@@ -1,12 +0,0 @@
"use client";
import { useState } from "react";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
export default function PublicTraceActions({ lotNumber }: { lotNumber: string }) {
return (
<div className="flex items-center justify-center gap-2 mb-4">
<ShareTraceButton lotNumber={lotNumber} />
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
"use client";
type EmptyStateProps = {
title: string;
description?: string;
icon?: string;
action?: React.ReactNode;
className?: string;
};
export default function EmptyState({ title, description, icon, action, className = "" }: EmptyStateProps) {
return (
<div className={`flex flex-col items-center justify-center py-16 px-6 text-center ${className}`}>
{icon && <span className="mb-4 text-4xl">{icon}</span>}
{!icon && (
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-slate-100">
<svg className="h-6 w-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
)}
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
{description && <p className="mt-1 text-sm text-slate-500 max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
-121
View File
@@ -1,121 +0,0 @@
"use client";
type SkeletonVariant = "text" | "card" | "table" | "avatar" | "button";
type SkeletonProps = {
variant?: SkeletonVariant;
width?: string;
height?: string;
className?: string;
count?: number;
};
function SkeletonLine({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-stone-200 ${className}`} />;
}
export function SkeletonText({ lines = 3, className = "" }: { lines?: number; className?: string }) {
return (
<div className={`space-y-2.5 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonLine key={i} className={i === lines - 1 ? "w-3/4" : "w-full"} />
))}
</div>
);
}
export function SkeletonCard({ className = "" }: SkeletonProps) {
return (
<div className={`rounded-2xl border border-stone-200 bg-white p-6 ${className}`}>
<div className="flex items-center gap-4">
<SkeletonLine className="h-10 w-10 rounded-xl" />
<div className="flex-1 space-y-2.5">
<SkeletonLine className="h-4 w-1/3" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, cols = 4, className = "" }: { rows?: number; cols?: number; className?: string }) {
return (
<div className={`overflow-hidden rounded-2xl border border-stone-200 bg-white ${className}`}>
<div className="border-b border-stone-100 bg-stone-50 px-5 py-4">
<div className="flex gap-6">
{Array.from({ length: cols }).map((_, i) => (
<SkeletonLine key={i} className="h-3 flex-1 max-w-24" />
))}
</div>
</div>
<div className="divide-y divide-stone-100">
{Array.from({ length: rows }).map((_, rowIdx) => (
<div key={rowIdx} className="flex items-center gap-6 px-5 py-4">
{Array.from({ length: cols }).map((_, colIdx) => (
<SkeletonLine key={colIdx} className="h-3 flex-1 max-w-32" />
))}
</div>
))}
</div>
</div>
);
}
export function SkeletonAvatar({ className = "" }: SkeletonProps) {
return <SkeletonLine className={`h-10 w-10 rounded-full ${className}`} />;
}
export function SkeletonButton({ className = "" }: SkeletonProps) {
return <SkeletonLine className={`h-10 w-28 rounded-xl ${className}`} />;
}
export default function LoadingSkeleton({ variant = "text", width, height, className = "", count = 1 }: SkeletonProps) {
const style: React.CSSProperties = {};
if (width) style.width = width;
if (height) style.height = height;
// Pre-compute stable keys per item position so the JSX below doesn't need
// to reference the map index variable (oxlint no-array-index-as-key).
const items = Array.from({ length: count }, (_, position) => ({
key: `skeleton-${variant}-${position}`,
}));
if (variant === "card") {
return (
<div className="space-y-4">
{items.map((item) => (
<SkeletonCard key={item.key} />
))}
</div>
);
}
if (variant === "table") {
return <SkeletonTable className={className} />;
}
if (variant === "avatar") {
return (
<div className="flex items-center gap-3">
{items.map((item) => (
<SkeletonAvatar key={item.key} />
))}
</div>
);
}
if (variant === "button") {
return (
<div className="flex gap-3">
{items.map((item) => (
<SkeletonButton key={item.key} />
))}
</div>
);
}
// default: text
return (
<div className="space-y-2.5">
{items.map((item) => (
<SkeletonText key={item.key} className={className} />
))}
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
type SectionHeaderProps = {
title: string;
description?: string;
titleClassName?: string;
descClassName?: string;
};
export default function SectionHeader({
title,
description,
titleClassName,
descClassName,
}: SectionHeaderProps) {
return (
<div className="mb-8">
<h2 className={`text-3xl font-bold tracking-tight text-stone-950 ${titleClassName ?? ""}`}>
{title}
</h2>
{description && (
<p className={`mt-2 max-w-2xl text-stone-700 leading-relaxed ${descClassName ?? ""}`}>
{description}
</p>
)}
</div>
);
}
-44
View File
@@ -1,44 +0,0 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useTheme } from "next-themes";
import { useEffect, useState, useSyncExternalStore } from "react";
export default function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme();
// Track whether we're on the client using useSyncExternalStore so the
// initial value comes from a snapshot rather than a useEffect — this
// avoids the "extra render with empty state" the
// `no-initialize-state` rule warns about.
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
if (!mounted) {
return <div className={`h-5 w-5 ${className ?? ""}`} />;
}
const isDark = resolvedTheme === "dark";
return (
<button type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-all border border-transparent hover:border-white/10 ${className ?? ""}`}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
// Sun icon
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
// Moon icon
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}
@@ -1,98 +0,0 @@
"use client";
import Link from "next/link";
export type BreadcrumbItem = {
label: string;
href?: string;
};
export type BreadcrumbNavProps = {
/** Array of breadcrumb items. Last item is the current page (no href). */
items: BreadcrumbItem[];
/** Optional brand accent for styling: 'green' (Tuxedo) or 'blue' (Indian River) */
brandAccent?: "green" | "blue";
/** Optional additional CSS classes */
className?: string;
};
/**
* Breadcrumb navigation component following Apple HIG.
* Uses semantic <nav> with aria-label and ordered list for accessibility.
*/
export default function BreadcrumbNav({
items,
brandAccent = "green",
className = "",
}: BreadcrumbNavProps) {
if (!items || items.length === 0) {
return null;
}
const accentColor = brandAccent === "blue"
? "text-blue-600 hover:text-blue-700"
: "text-emerald-600 hover:text-emerald-700";
const separatorColor = "text-stone-400";
return (
<nav
aria-label="Breadcrumb"
className={`w-full ${className}`}
>
<ol className="flex items-center flex-wrap gap-2 text-sm">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li
key={`${item.label}-${item.href ?? ""}`}
className="flex items-center"
>
{index > 0 && (
<span
className={`mx-2 ${separatorColor}`}
aria-hidden="true"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</span>
)}
{isLast ? (
<span
className="font-medium text-stone-700"
aria-current="page"
>
{item.label}
</span>
) : item.href ? (
<Link
href={item.href}
className={`font-medium transition-colors ${accentColor}`}
>
{item.label}
</Link>
) : (
<span className={`font-medium ${accentColor}`}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
@@ -1,21 +0,0 @@
"use client";
/**
* IndianRiverGrainOverlay
*
* Atmospheric grain texture component for Indian River Direct storefront.
* Provides the subtle film-grain texture that creates a premium, organic feel
* reminiscent of high-end agricultural photography.
*
* Uses a blue/emerald color scheme to complement the citrus brand identity.
*/
export default function IndianRiverGrainOverlay() {
return (
<div
className="fixed inset-0 pointer-events-none z-50 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
);
}
@@ -1,179 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import StopCard from "./StopCard";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
brand_id: string;
};
type Props = {
stops: Stop[];
brandSlug: string;
};
const THIS_WEEK_LIMIT = 10;
const TWO_WEEKS_LIMIT = 10;
const FULL_SEASON_PAGE_SIZE = 20;
type Tab = "this_week" | "next_two_weeks" | "full_season";
function getDateRange(tab: Tab): { start: Date; end: Date } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const addDays = (d: Date, n: number) => {
const copy = new Date(d);
copy.setDate(copy.getDate() + n);
return copy;
};
if (tab === "this_week") return { start: today, end: addDays(new Date(today), 7) };
if (tab === "next_two_weeks") return { start: today, end: addDays(new Date(today), 14) };
return { start: new Date(0), end: new Date("2100-01-01") };
}
export default function IndianRiverPaginatedStops({ stops, brandSlug }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("this_week");
const [fullSeasonPage, setFullSeasonPage] = useState(0);
const [fullSeasonSearch, setFullSeasonSearch] = useState("");
const { start, end } = getDateRange(activeTab);
const filtered = useMemo(() => {
if (activeTab === "full_season") {
if (!fullSeasonSearch.trim()) return stops;
const q = fullSeasonSearch.toLowerCase();
return stops.filter(
(s) =>
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q)
);
}
return stops.filter((s) => {
const d = new Date(s.date);
return d >= start && d <= end;
});
}, [activeTab, stops, start, end, fullSeasonSearch]);
const paginatedStops = useMemo(() => {
if (activeTab === "this_week") return filtered.slice(0, THIS_WEEK_LIMIT);
if (activeTab === "next_two_weeks") return filtered.slice(0, TWO_WEEKS_LIMIT);
const offset = fullSeasonPage * FULL_SEASON_PAGE_SIZE;
return filtered.slice(offset, offset + FULL_SEASON_PAGE_SIZE);
}, [activeTab, filtered, fullSeasonPage]);
const totalCount = filtered.length;
const totalStops = stops.length;
const totalPages = Math.ceil(totalCount / FULL_SEASON_PAGE_SIZE);
const handleTabChange = (tab: Tab) => {
setActiveTab(tab);
setFullSeasonPage(0);
setFullSeasonSearch("");
};
return (
<div>
{/* Header row */}
<div className="mb-8 flex items-center justify-between">
<p className="text-sm text-stone-400 font-medium">
{totalStops} stop{totalStops !== 1 ? "s" : ""} this season
</p>
</div>
{/* Tabs */}
<div className="mb-10 flex gap-2 flex-wrap">
{(["this_week", "next_two_weeks", "full_season"] as Tab[]).map((tab) => (
<button type="button"
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-2xl px-5 py-3 text-sm font-semibold transition-colors ${
activeTab === tab
? "bg-blue-600 text-white"
: "bg-white text-stone-500 ring-1 ring-stone-200 hover:bg-stone-50"
}`}
>
{tab === "this_week"
? "This Week"
: tab === "next_two_weeks"
? "Next 2 Weeks"
: "Full Season"}
</button>
))}
</div>
{/* Search — full season */}
{activeTab === "full_season" && (
<div className="mb-10 flex items-center gap-4 flex-col sm:flex-row">
<input aria-label="Search By City, Location, Or State..."
type="text"
placeholder="Search by city, location, or state..."
value={fullSeasonSearch}
onChange={(e) => {
setFullSeasonSearch(e.target.value);
setFullSeasonPage(0);
}}
className="flex-1 rounded-2xl border border-stone-200 bg-white px-5 py-3.5 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
/>
<span className="text-sm text-stone-500 whitespace-nowrap">
{totalCount > 0
? `${paginatedStops.length} of ${totalCount} stops`
: `${totalCount} stops`}
</span>
</div>
)}
{/* Empty state */}
{filtered.length === 0 ? (
<div className="rounded-3xl bg-white p-14 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No stops found for this period.</p>
</div>
) : (
<>
<div className="grid gap-6 md:grid-cols-3">
{paginatedStops.map((stop) => (
<StopCard
key={stop.id}
{...stop}
brandSlug={brandSlug}
brandAccent="blue"
/>
))}
</div>
{/* Pagination */}
{activeTab === "full_season" && totalPages > 1 && (
<div className="mt-10 flex items-center justify-between flex-col sm:flex-row gap-6">
<span className="text-sm text-stone-500">
Page {fullSeasonPage + 1} of {totalPages}
</span>
<div className="flex gap-3">
<button type="button"
onClick={() => setFullSeasonPage((p) => Math.max(0, p - 1))}
disabled={fullSeasonPage === 0}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Previous
</button>
<button type="button"
onClick={() => setFullSeasonPage((p) => p + 1)}
disabled={fullSeasonPage >= totalPages - 1}
className="rounded-2xl border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-600 disabled:opacity-40 hover:bg-stone-50 transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
-120
View File
@@ -1,120 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/format-date";
type ZipCodeSearchProps = {
allStops?: Array<{
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
}>;
brandAccent?: "green" | "orange" | "blue";
};
export default function ZipCodeSearch({ allStops = [], brandAccent = "blue" }: ZipCodeSearchProps) {
const router = useRouter();
const [zipCode, setZipCode] = useState("");
const [filteredStops, setFilteredStops] = useState<typeof allStops>([]);
const [searched, setSearched] = useState(false);
const isBlue = brandAccent === "blue";
return (
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${isBlue ? "bg-blue-50" : "bg-stone-100"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-stone-500"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold tracking-tight text-stone-950">Find Stops Near You</h2>
<p className="text-sm text-stone-500 mt-0.5">Search by ZIP code or city name</p>
</div>
</div>
{/* Search row */}
<div className="flex gap-3">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input aria-label="ZIP Code Or City..."
type="text"
placeholder="ZIP code or city..."
value={zipCode}
onChange={(e) => setZipCode(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { if (!zipCode.trim()) return; const normalized = zipCode.trim().replace(/\D/g, ""); setFilteredStops(allStops.filter((s) => s.city.toLowerCase().includes(normalized) || s.location.toLowerCase().includes(normalized) || s.state.toLowerCase().includes(normalized.toUpperCase()))); setSearched(true); } }}
className="w-full rounded-xl border border-stone-200 pl-11 pr-4 py-3.5 text-sm text-stone-900 placeholder-stone-400 bg-white outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors shadow-sm"
/>
</div>
<button type="button"
onClick={() => {
if (!zipCode.trim()) return;
const normalized = zipCode.trim().replace(/\D/g, "");
setFilteredStops(allStops.filter((s) =>
s.city.toLowerCase().includes(normalized) ||
s.location.toLowerCase().includes(normalized) ||
s.state.toLowerCase().includes(normalized.toUpperCase())
));
setSearched(true);
}}
className="rounded-xl bg-stone-900 px-7 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-all hover:-translate-y-0.5 shadow-sm"
>
Search
</button>
</div>
{/* Results */}
{searched && (
<div className="mt-6 pt-6 border-t border-stone-100">
{filteredStops.length > 0 ? (
<div className="space-y-3">
<p className="text-sm font-semibold text-stone-700">
{filteredStops.length} stop{filteredStops.length !== 1 ? "s" : ""} found
</p>
{filteredStops.map((stop) => (
<button type="button"
key={stop.id}
onClick={() => router.push(`#stops`)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-5 py-4 text-left hover:bg-stone-100 group transition-all"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-base text-stone-950">{stop.city}, {stop.state}</p>
<p className="mt-1 text-sm text-stone-500">{formatDate(stop.date)} &middot; {stop.time} &middot; {stop.location}</p>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-stone-200/60 mt-0.5">
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-stone-100 mb-4">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="font-medium text-stone-600">No stops found for that location.</p>
<p className="text-sm mt-1 text-stone-400">Try a nearby ZIP code or browse all stops below.</p>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,52 +0,0 @@
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
// Inline form used inside the SettingsTab to add a notification recipient.
export default function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => void }) {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
function isValidEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
}
function handleAdd() {
if (!email.trim()) return;
if (!isValidEmail(email.trim())) {
setError("Enter a valid email address.");
return;
}
setError("");
onAdd(email.trim(), name.trim());
setEmail("");
setName("");
}
return (
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
type="email" placeholder="Email address"
value={email} onChange={e => { setEmail(e.target.value); setError(""); }}
onKeyDown={e => e.key === "Enter" && handleAdd()}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" aria-label="Email address"/>
</div>
<div className="w-36">
<input
type="text" placeholder="Name (optional)"
value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleAdd()}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" aria-label="Name (optional)"/>
</div>
<AdminButton
variant="primary"
size="sm"
onClick={handleAdd}
>
Add
</AdminButton>
{error && <p className="text-xs text-[var(--admin-danger)] mt-1">{error}</p>}
</div>
);
}
@@ -1,124 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
import {
getWholesaleCustomerPricing,
upsertWholesaleCustomerPricing,
deleteWholesaleCustomerPricing,
} from "@/actions/wholesale-register";
import type { WholesaleCustomer, WholesaleProduct } from "@/actions/wholesale";
import type { MsgFn } from "./types";
interface CustomerPricingPanelProps {
customer: WholesaleCustomer;
products: WholesaleProduct[];
onClose: () => void;
onMsg: MsgFn;
}
// Modal panel that lets an admin override per-product pricing for a wholesale customer.
// Blank override clears the override and falls back to the standard tier.
export default function CustomerPricingPanel({ customer, products, onClose, onMsg }: CustomerPricingPanelProps) {
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
getWholesaleCustomerPricing(customer.id).then(pricing => {
const map: Record<string, string> = {};
for (const p of pricing) {
map[p.product_id] = p.custom_unit_price.toString();
}
setOverrides(map);
setLoading(false);
});
}, [customer.id]);
async function handleSave(productId: string, price: string) {
if (!price || isNaN(Number(price))) {
await deleteWholesaleCustomerPricing({ customerId: customer.id, productId });
setOverrides(prev => { const n = { ...prev }; delete n[productId]; return n; });
} else {
await upsertWholesaleCustomerPricing({ customerId: customer.id, productId, customUnitPrice: Number(price) });
setOverrides(prev => ({ ...prev, [productId]: price }));
}
onMsg("success", "Pricing updated.");
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Pricing Overrides</h2>
<p className="text-sm text-[var(--admin-text-muted)]">{customer.company_name}</p>
</div>
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4">
{loading ? (
<p className="text-[var(--admin-text-muted)] text-sm">Loading...</p>
) : products.length === 0 ? (
<p className="text-[var(--admin-text-muted)] text-sm">No products available. Add wholesale products to see them here.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-2 font-medium">Product</th>
<th className="pb-2 font-medium">Standard Price</th>
<th className="pb-2 font-medium">Override Price</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.map(p => {
const standardPrice = p.price_tiers?.[0]?.price ?? 0;
const overridePrice = overrides[p.id] ?? "";
return (
<tr key={p.id}>
<td className="py-2.5 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="py-2.5 text-[var(--admin-text-muted)]">${standardPrice.toFixed(2)}</td>
<td className="py-2.5">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--admin-text-muted)]">$</span>
<input
type="number"
step="0.01"
min="0"
value={overridePrice}
onChange={e => setOverrides(prev => ({ ...prev, [p.id]: e.target.value }))}
placeholder={standardPrice.toFixed(2)}
className="w-24 rounded-lg border border-[var(--admin-border)] px-2 py-1 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
{overridePrice && overridePrice !== standardPrice.toFixed(2) && (
<span className="text-xs text-[var(--admin-accent)] font-medium">Custom</span>
)}
</div>
</td>
<td className="py-2.5">
<AdminButton
variant="primary"
size="sm"
onClick={() => handleSave(p.id, overrides[p.id] ?? "")}
disabled={saving}
>
Save
</AdminButton>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<p className="mt-3 text-xs text-[var(--admin-text-muted)]">
Leave override price blank to remove custom pricing and use standard price tiers.
</p>
</div>
</div>
</div>
);
}
@@ -1,531 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
import {
type WholesaleCustomer,
type WholesaleProduct,
deleteWholesaleCustomer,
saveWholesaleCustomer,
} from "@/actions/wholesale";
import { approveWholesaleRegistration } from "@/actions/wholesale-register";
import { formatDate } from "@/lib/format-date";
import CustomerPricingPanel from "./CustomerPricingPanel";
import PriceSheetModal from "./PriceSheetModal";
import type { MsgFn, PendingRegistration } from "./types";
interface CustomersTabProps {
customers: WholesaleCustomer[];
products: WholesaleProduct[];
brandId: string;
onMsg: MsgFn;
registrations?: PendingRegistration[];
onRefresh: () => void;
}
// Customers tab — list/approve pending registrations, manage customers, send
// price sheets, edit per-customer pricing overrides.
export default function CustomersTab({
customers,
products,
brandId,
onMsg,
registrations = [],
onRefresh,
}: CustomersTabProps) {
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<WholesaleCustomer | null>(null);
const [subTab, setSubTab] = useState<"customers" | "registrations">("customers");
const [form, setForm] = useState({
companyName: "", contactName: "", email: "", phone: "",
accountStatus: "active", creditLimit: 0,
depositsEnabled: false, depositThreshold: "", depositPercentage: "",
orderEmail: "", invoiceEmail: "", adminNotes: "",
});
const [saving, setSaving] = useState(false);
const [processingReg, setProcessingReg] = useState<string | null>(null);
const [pricingCustomer, setPricingCustomer] = useState<WholesaleCustomer | null>(null);
const [selectedCustomers, setSelectedCustomers] = useState<Set<string>>(new Set());
const [sendingPriceSheet, setSendingPriceSheet] = useState(false);
const [priceSheetTarget, setPriceSheetTarget] = useState<{
customerIds: string[];
defaultSubject: string;
} | null>(null);
const [openCustomerActions, setOpenCustomerActions] = useState<string | null>(null);
const [deletingCustomer, setDeletingCustomer] = useState<string | null>(null);
function toggleSelectCustomer(id: string) {
setSelectedCustomers(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function toggleAllCustomers() {
if (selectedCustomers.size === customers.length) {
setSelectedCustomers(new Set());
} else {
setSelectedCustomers(new Set(customers.map(c => c.id)));
}
}
function openPriceSheetModal(customerIds: string[]) {
const ids = customerIds;
if (ids.length === 0) return;
setPriceSheetTarget({
customerIds: ids,
defaultSubject: `Wholesale Price Sheet — ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`,
});
}
async function handleConfirmPriceSheet(subject: string, customNote: string) {
if (!priceSheetTarget) return;
setSendingPriceSheet(true);
setPriceSheetTarget(null);
try {
const res = await fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customerIds: priceSheetTarget.customerIds,
brandId,
subject,
customNote: customNote || undefined,
}),
});
const data = await res.json();
if (res.ok) {
onMsg("success", `Price sheet sent to ${data.enqueued} customer(s).`);
setSelectedCustomers(new Set());
} else {
onMsg("error", data.error ?? "Failed to send price sheet.");
}
} catch {
onMsg("error", "Failed to send price sheet.");
} finally {
setSendingPriceSheet(false);
}
}
async function handleApproveReject(regId: string, action: "approve" | "reject") {
setProcessingReg(regId);
const result = await approveWholesaleRegistration(regId, brandId, action);
setProcessingReg(null);
if (result.success) {
onMsg("success", action === "approve" ? "Registration approved." : "Registration rejected.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to process registration.");
}
}
// Close customer actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".customer-actions-cell")) {
setOpenCustomerActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleCustomerActions(customerId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenCustomerActions(prev => prev === customerId ? null : customerId);
}
async function handleDeleteCustomer(customerId: string) {
if (!confirm("Delete this customer? This cannot be undone. Customers with existing orders cannot be deleted.")) return;
setDeletingCustomer(customerId);
const result = await deleteWholesaleCustomer(customerId);
setDeletingCustomer(null);
if (result.success) {
onMsg("success", "Customer deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete customer.");
}
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleCustomer({
brandId,
userId: editing?.user_id ?? undefined,
companyName: form.companyName || undefined,
contactName: form.contactName || undefined,
email: form.email || undefined,
phone: form.phone || undefined,
accountStatus: form.accountStatus,
creditLimit: form.creditLimit,
depositsEnabled: form.depositsEnabled,
depositThreshold: form.depositThreshold ? Number(form.depositThreshold) : undefined,
depositPercentage: form.depositPercentage ? Number(form.depositPercentage) : undefined,
orderEmail: form.orderEmail || undefined,
invoiceEmail: form.invoiceEmail || undefined,
adminNotes: form.adminNotes || undefined,
});
setSaving(false);
if (result.success) {
onMsg("success", editing ? "Customer updated." : "Customer created.");
setShowForm(false);
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
function openNew() {
setEditing(null);
setForm({ companyName: "", contactName: "", email: "", phone: "", accountStatus: "active", creditLimit: 0, depositsEnabled: false, depositThreshold: "", depositPercentage: "", orderEmail: "", invoiceEmail: "", adminNotes: "" });
setShowForm(true);
}
function openEdit(c: WholesaleCustomer) {
setEditing(c);
setForm({
companyName: c.company_name ?? "",
contactName: c.contact_name ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
accountStatus: c.account_status ?? "active",
creditLimit: Number(c.credit_limit),
depositsEnabled: c.deposits_enabled,
depositThreshold: c.deposit_threshold?.toString() ?? "",
depositPercentage: c.deposit_percentage?.toString() ?? "",
orderEmail: c.order_email ?? "",
invoiceEmail: c.invoice_email ?? "",
adminNotes: c.admin_notes ?? "",
});
setShowForm(true);
}
return (
<div className="space-y-4">
{/* Sub-tab nav */}
<div className="flex items-center gap-4">
<AdminButton
variant={subTab === "customers" ? "primary" : "secondary"}
size="sm"
onClick={() => setSubTab("customers")}
>
Customers ({customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length})
</AdminButton>
<AdminButton
variant={subTab === "registrations" ? "primary" : "secondary"}
size="sm"
onClick={() => setSubTab("registrations")}
>
Registrations ({registrations.filter(r => r.account_status === "pending_approval").length})
</AdminButton>
{subTab === "customers" && (
<AdminButton
variant="primary"
size="sm"
onClick={openNew}
className="ml-auto"
>
+ Add Customer
</AdminButton>
)}
</div>
{subTab === "registrations" && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-[var(--admin-text-secondary)]">Pending Registrations</h3>
{registrations.filter(r => r.account_status === "pending_approval").length === 0 ? (
<p className="text-sm text-[var(--admin-text-muted)] py-4 text-center">No pending registrations.</p>
) : (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Company</th>
<th className="px-5 py-3 font-semibold text-left">Contact</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Registered</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{registrations.flatMap(r =>
r.account_status === "pending_approval" ? [r] : []
).map(r => (
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
<td className="px-5 py-3">
<AdminBadge variant="warning">Pending</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(r.created_at))}</td>
<td className="px-5 py-3">
<div className="flex gap-2">
<AdminButton
variant="primary"
size="sm"
onClick={() => handleApproveReject(r.id, "approve")}
disabled={processingReg === r.id}
isLoading={processingReg === r.id}
>
{processingReg === r.id ? "..." : "Approve"}
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={() => handleApproveReject(r.id, "reject")}
disabled={processingReg === r.id}
>
Reject
</AdminButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{subTab === "customers" && (
<>
{showForm && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
<input id="ws-company-name" value={form.companyName} onChange={e => setForm(f => ({ ...f, companyName: 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="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
<input id="ws-contact-name" value={form.contactName} onChange={e => setForm(f => ({ ...f, contactName: 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="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
<input id="ws-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: 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)]" autoComplete="email" />
</div>
<div>
<label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
<input id="ws-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: 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)]" autoComplete="tel" />
</div>
<div>
<label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
<select id="ws-account-status" value={form.accountStatus} onChange={e => setForm(f => ({ ...f, accountStatus: 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)]">
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="disabled">Disabled</option>
<option value="pending_approval">Pending Approval</option>
</select>
</div>
<div>
<label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
<input id="ws-credit-limit" type="number" value={form.creditLimit} onChange={e => setForm(f => ({ ...f, creditLimit: Number(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="0 = unlimited" aria-label="0 = unlimited"/>
</div>
</div>
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] space-y-3">
<p className="text-sm font-semibold text-[var(--admin-text-secondary)]">Deposit Rules</p>
<div className="flex items-center gap-3">
<input type="checkbox" checked={form.depositsEnabled}
onChange={e => setForm(f => ({ ...f, depositsEnabled: e.target.checked }))}
className="rounded" id="dep-enabled" />
<label htmlFor="dep-enabled" className="text-sm text-[var(--admin-text-secondary)]">Enable deposit requirement</label>
</div>
{form.depositsEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
<input id="ws-deposit-threshold" type="number" value={form.depositThreshold} onChange={e => setForm(f => ({ ...f, depositThreshold: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" aria-label="0"/>
</div>
<div>
<label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
<input id="ws-deposit-pct" type="number" value={form.depositPercentage} onChange={e => setForm(f => ({ ...f, depositPercentage: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" aria-label="20"/>
</div>
</div>
)}
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} disabled={saving} isLoading={saving} variant="primary">
{saving ? "Saving..." : "Save Customer"}
</AdminButton>
<AdminButton onClick={() => setShowForm(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
)}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">
<input type="checkbox" className="rounded border-[var(--admin-border)]"
checked={selectedCustomers.size === customers.length && customers.length > 0}
onChange={toggleAllCustomers}
/>
</th>
<th className="px-5 py-3 font-semibold text-left">Company</th>
<th className="px-5 py-3 font-semibold text-left">Contact</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Credit</th>
<th className="px-5 py-3 font-semibold text-left">Deposits</th>
<th className="px-5 py-3 font-semibold text-left">Created</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{customers.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No customers yet</p>
<p className="text-xs text-slate-400">Wholesale customers will appear here once registered.</p>
</div>
</td>
</tr>
) : customers.map(c => (
<tr key={c.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3">
<input type="checkbox" className="rounded border-[var(--admin-border)]"
checked={selectedCustomers.has(c.id)}
onChange={() => toggleSelectCustomer(c.id)}
/>
</td>
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{c.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{c.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{c.email}</span></td>
<td className="px-5 py-3">
<AdminBadge variant={c.account_status === "active" ? "success" : c.account_status === "on_hold" ? "warning" : "danger"}>
{c.account_status}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
{c.credit_limit <= 0 ? "Unlimited" : `$${Number(c.credit_limit).toFixed(2)}`}
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
{c.deposits_enabled ? `${c.deposit_percentage}%` : "—"}
</td>
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(c.created_at))}</td>
<td className="px-5 py-4 customer-actions-cell relative">
<div className="flex items-center gap-1.5">
<button type="button"
onClick={() => openPriceSheetModal([c.id])}
title="Send price sheet"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-accent)] hover:text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)] transition-colors"
aria-label="Send price sheet">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</button>
<div className="relative">
<button type="button"
onClick={(e) => toggleCustomerActions(c.id, e)}
title="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
aria-label="More actions">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{openCustomerActions === c.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-52 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { setOpenCustomerActions(null); openEdit(c); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
aria-label="Edit Customer">
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Customer
</button>
<button type="button"
onClick={() => { setOpenCustomerActions(null); setPricingCustomer(c); }}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
aria-label="Pricing">
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Pricing
</button>
<a
href={`/wholesale/portal?preview_customer_id=${c.id}&_admin_preview=1`}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
View Portal As
</a>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button type="button"
onClick={() => { setOpenCustomerActions(null); handleDeleteCustomer(c.id); }}
disabled={deletingCustomer === c.id}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{deletingCustomer === c.id ? "Deleting..." : "Delete Customer"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
{selectedCustomers.size > 0 && (
<tfoot className="bg-[var(--admin-accent-light)] border-t border-[var(--admin-accent)]">
<tr>
<td colSpan={8} className="px-5 py-3 flex items-center gap-4">
<span className="text-sm text-[var(--admin-accent-text)] font-medium">{selectedCustomers.size} selected</span>
<AdminButton
onClick={() => openPriceSheetModal(Array.from(selectedCustomers))}
disabled={sendingPriceSheet}
variant="primary"
size="sm"
isLoading={sendingPriceSheet}
>
{sendingPriceSheet ? "Sending..." : `Send Price Sheet to ${selectedCustomers.size} Customer(s)`}
</AdminButton>
</td>
</tr>
</tfoot>
)}
</table>
</div>
</>
)}
{/* Customer Pricing Overlays Panel */}
{pricingCustomer && (
<CustomerPricingPanel
customer={pricingCustomer}
products={products}
onClose={() => setPricingCustomer(null)}
onMsg={onMsg}
/>
)}
{/* Price Sheet Modal */}
{priceSheetTarget && (
<PriceSheetModal
customerCount={priceSheetTarget.customerIds.length}
defaultSubject={priceSheetTarget.defaultSubject}
onConfirm={handleConfirmPriceSheet}
onClose={() => { setPriceSheetTarget(null); setSendingPriceSheet(false); }}
/>
)}
</div>
);
}
@@ -1,126 +0,0 @@
import type { WholesaleOrder, WholesaleDashboardStats } from "@/actions/wholesale";
import StatusBadge from "./StatusBadge";
import type { MsgFn, WebhookActivityEntry } from "./types";
interface DashboardTabProps {
stats: WholesaleDashboardStats;
recentOrders: WholesaleOrder[];
webhookActivity: WebhookActivityEntry[];
}
// Top-level dashboard: stat cards + recent orders table + recent webhook activity.
export default function DashboardTab({ stats, recentOrders, webhookActivity }: DashboardTabProps) {
return (
<div className="space-y-6">
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{[
{ label: "Open Orders", value: stats.open_orders, variant: "default" },
{ label: "Pickup Today", value: stats.pickup_today, variant: "default" },
{ label: "Past Due", value: stats.past_due, variant: "danger" },
{ label: "Total Unpaid", value: `$${stats.total_unpaid.toFixed(2)}`, variant: "default" },
{ label: "Awaiting Deposit", value: stats.awaiting_deposit, variant: "warning" },
{ label: "Fulfilled Today", value: stats.fulfilled_today, variant: "success" },
].map((card) => (
<div key={card.label} className={`rounded-xl border shadow-sm ${card.variant === "danger" ? "bg-[var(--admin-danger-light)] border-[var(--admin-danger)]" : card.variant === "warning" ? "bg-[var(--admin-warning-light)] border-[var(--admin-warning)]" : card.variant === "success" ? "bg-[var(--admin-accent-light)] border-[var(--admin-accent)]" : "bg-white border-[var(--admin-border)]"}`}>
<p className={`text-xs ${card.variant === "danger" ? "text-[var(--admin-danger)]" : card.variant === "warning" ? "text-[var(--admin-warning)]" : card.variant === "success" ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-muted)]"}`}>{card.label}</p>
<p className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]">{card.value}</p>
</div>
))}
</div>
{/* Recent orders */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Orders</h2>
{recentOrders.length === 0 ? (
<div className="text-center py-12">
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<p className="text-base font-semibold text-slate-600 mb-1">No wholesale orders yet</p>
<p className="text-sm text-slate-400">Wholesale orders placed by your customers will appear here.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-3 font-medium">Invoice</th>
<th className="pb-3 font-medium">Customer</th>
<th className="pb-3 font-medium">Pickup Date</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Status</th>
<th className="pb-3 font-medium">Payment</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{recentOrders.map((order) => (
<tr key={order.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{order.invoice_number ?? "—"}</td>
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{order.company_name}</td>
<td className="py-3 text-[var(--admin-text-secondary)]">{order.anticipated_pickup_date ?? "—"}</td>
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(order.subtotal).toFixed(2)}</td>
<td className="py-3">
<StatusBadge status={order.status} />
</td>
<td className="py-3">
<span className={`text-xs font-medium ${
order.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"
}`}>
{order.payment_status === "paid" ? "Paid" : order.balance_due > 0 ? `$${Number(order.balance_due).toFixed(2)} due` : "Partial"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Recent webhook activity */}
{webhookActivity.length > 0 && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Webhook Activity</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-3 font-medium">Event</th>
<th className="pb-3 font-medium">Order</th>
<th className="pb-3 font-medium">Status</th>
<th className="pb-3 font-medium">Attempts</th>
<th className="pb-3 font-medium">Sent At</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{webhookActivity.map((entry) => (
<tr key={entry.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="py-3">
<span className="font-mono text-xs bg-[var(--admin-accent)] text-white px-2 py-0.5 rounded">{entry.event_type}</span>
</td>
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{entry.order_id ? entry.order_id.slice(0, 8) : "—"}</td>
<td className="py-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
entry.status === "sent" ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" :
entry.status === "failed" ? "bg-[var(--admin-danger-light)] text-[var(--admin-danger)]" :
entry.status === "retrying" ? "bg-[var(--admin-warning-light)] text-[var(--admin-warning)]" :
"bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
}`}>
{entry.status}
</span>
</td>
<td className="py-3 text-[var(--admin-text-muted)]">{entry.attempts}</td>
<td className="py-3 text-[var(--admin-text-muted)]">{new Date(entry.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -1,538 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminSearchInput } from "@/components/admin/design-system";
import { openHtmlInPopup } from "@/lib/safe-window";
import {
type WholesaleOrder,
type WholesaleCustomer,
bulkFulfillWholesaleOrders,
bulkRecordWholesaleDeposit,
markWholesaleOrderFulfilled,
recordWholesaleDeposit,
updateWholesaleOrderStatus,
deleteWholesaleOrder,
getWholesaleSettings,
enqueueWholesaleNotification,
} from "@/actions/wholesale";
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
import StatusBadge from "./StatusBadge";
import type { MsgFn } from "./types";
interface OrdersTabProps {
orders: WholesaleOrder[];
customers: WholesaleCustomer[];
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
}
// Orders tab — list/filter orders, fulfill, record deposits, bulk operations,
// cancel/delete, generate manifests, and dispatch notification emails on fulfillment.
export default function OrdersTab({ orders, customers, brandId, onMsg, onRefresh }: OrdersTabProps) {
const [showDepForm, setShowDepForm] = useState<string | null>(null);
const [depAmount, setDepAmount] = useState("");
const [depMethod, setDepMethod] = useState("cash");
const [fulfilling, setFulfilling] = useState<string | null>(null);
const [manifestLoading, setManifestLoading] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState("all");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [bulkDepAmount, setBulkDepAmount] = useState("");
const [bulkDepMethod, setBulkDepMethod] = useState("cash");
const [showBulkDep, setShowBulkDep] = useState(false);
const [bulkLoading, setBulkLoading] = useState(false);
const [openActions, setOpenActions] = useState<string | null>(null);
const [showViewOrder, setShowViewOrder] = useState<WholesaleOrder | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const filtered = orders.filter(o => {
if (statusFilter !== "all" && o.status !== statusFilter) return false;
if (dateFrom && (!o.anticipated_pickup_date || o.anticipated_pickup_date < dateFrom)) return false;
if (dateTo && (!o.anticipated_pickup_date || o.anticipated_pickup_date > dateTo)) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
if (!(o.company_name?.toLowerCase().includes(q) || o.invoice_number?.toLowerCase().includes(q) || o.customer_email?.toLowerCase().includes(q))) return false;
}
return true;
});
function toggleSelect(id: string) {
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
function toggleAll() {
if (selected.size === filtered.length) { setSelected(new Set()); }
else { setSelected(new Set(filtered.map(o => o.id))); }
}
async function handleBulkFulfill() {
if (selected.size === 0) return;
setBulkLoading(true);
const result = await bulkFulfillWholesaleOrders(Array.from(selected));
setBulkLoading(false);
if (result.success) {
onMsg("success", `${result.count} order(s) marked as fulfilled.`);
setSelected(new Set());
onRefresh();
} else {
onMsg("error", result.error ?? "Bulk fulfill failed.");
}
}
async function handleBulkDeposit() {
if (selected.size === 0 || !bulkDepAmount) return;
setBulkLoading(true);
const result = await bulkRecordWholesaleDeposit(Array.from(selected), Number(bulkDepAmount), bulkDepMethod);
setBulkLoading(false);
if (result.success) {
onMsg("success", `Deposit recorded on ${result.count} order(s).`);
setSelected(new Set());
setShowBulkDep(false);
setBulkDepAmount("");
onRefresh();
} else {
onMsg("error", result.error ?? "Bulk deposit failed.");
}
}
async function handleFulfill(orderId: string) {
setFulfilling(orderId);
const result = await markWholesaleOrderFulfilled(orderId);
setFulfilling(null);
if (result.success) {
onMsg("success", "Order marked as fulfilled.");
onRefresh();
// Enqueue order fulfilled notification
const order = orders.find(o => o.id === orderId);
const customer = order ? customers.find(c => c.email === order.customer_email) : null;
if (order && customer) {
enqueueWholesaleNotification({
brandId: brandId,
customerId: customer.id,
orderId,
type: "order_fulfilled",
emailTo: customer.email,
subject: `Order ${order.invoice_number ?? orderId.slice(0, 8)} Ready for Pickup`,
bodyHtml: `
<h2>Your Order is Ready!</h2>
<p>Hi ${customer.company_name},</p>
<p>Your wholesale order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}.</p>
<p>Thank you for your business!</p>
`,
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}. Thank you!`,
});
// Also notify the admin
const settings = await getWholesaleSettings(brandId);
const adminEmail = settings?.notification_email ?? settings?.from_email ?? settings?.invoice_business_email ?? null;
if (adminEmail) {
enqueueWholesaleNotification({
brandId: brandId,
customerId: customer.id,
orderId,
type: "order_fulfilled",
emailTo: adminEmail,
subject: `[Admin] Order Fulfilled — ${order.invoice_number ?? orderId.slice(0, 8)}`,
bodyHtml: `
<h2>Order Fulfilled</h2>
<p>Order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> for <strong>${customer.company_name}</strong> has been marked as fulfilled.</p>
${order.anticipated_pickup_date ? `<p><strong>Pickup Date:</strong> ${order.anticipated_pickup_date}</p>` : ""}
`,
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} for ${customer.company_name} has been fulfilled.`,
});
}
// Trigger notification send (fire-and-forget)
fetch(`/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
}
} else {
onMsg("error", result.error ?? "Failed to fulfill order.");
}
}
// Close actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".actions-cell")) {
setOpenActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleActions(orderId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenActions(prev => prev === orderId ? null : orderId);
}
async function handleRecordDeposit(orderId: string) {
const result = await recordWholesaleDeposit(orderId, Number(depAmount), depMethod);
setShowDepForm(null);
setDepAmount("");
if (result.success) {
onMsg("success", "Deposit recorded.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to record deposit.");
}
}
async function handleCancelOrder(orderId: string) {
if (!confirm("Cancel this order? This cannot be undone.")) return;
const result = await updateWholesaleOrderStatus(orderId, "cancelled");
if (result.success) {
onMsg("success", "Order cancelled.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to cancel order.");
}
}
async function handleDeleteOrder(orderId: string) {
if (!confirm("Delete this order? Fulfilled and paid orders cannot be deleted. This cannot be undone.")) return;
setDeleting(orderId);
const result = await deleteWholesaleOrder(orderId);
setDeleting(null);
if (result.success) {
onMsg("success", "Order deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete order.");
}
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Orders ({orders.length})</h2>
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-3 rounded-2xl bg-white border border-[var(--admin-border)] p-4 shadow-sm">
<div>
<label htmlFor="fld-1-status" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
<select id="fld-1-status" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="awaiting_deposit">Awaiting Deposit</option>
<option value="confirmed">Confirmed</option>
<option value="fulfilled">Fulfilled</option>
</select>
</div>
<div>
<label htmlFor="fld-2-pickup-from" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup From</label>
<input id="fld-2-pickup-from" type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-3-pickup-to" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup To</label>
<input id="fld-3-pickup-to" type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="flex-1 min-w-[180px]">
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1" htmlFor="fld-search">Search</label>
<AdminSearchInput
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery("")}
placeholder="Invoice, customer, email..."
/>
</div>
{filtered.length !== orders.length && (
<div className="self-end pb-1">
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length} of {orders.length}</span>
</div>
)}
</div>
{/* Bulk actions */}
{selected.size > 0 && (
<div className="flex items-center gap-3 rounded-2xl bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-4 py-3">
<span className="text-sm font-medium text-[var(--admin-accent-text)]">{selected.size} selected</span>
<AdminButton variant="primary" size="sm" onClick={handleBulkFulfill} disabled={bulkLoading} isLoading={bulkLoading}>
{bulkLoading ? "..." : "Bulk Fulfill"}
</AdminButton>
<AdminButton variant="secondary" size="sm" onClick={() => setShowBulkDep(true)}>
Bulk Record Deposit
</AdminButton>
<button type="button" onClick={() => setSelected(new Set())} className="ml-auto text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Clear selection</button>
</div>
)}
{/* Manifest section */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-4 flex items-center gap-4 shadow-sm">
<div>
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Load Manifest</p>
<p className="text-xs text-[var(--admin-text-muted)]">Generate a printable manifest for pending/active orders.</p>
</div>
<AdminButton
variant="secondary"
size="sm"
onClick={async () => {
setManifestLoading(true);
const pending = filtered.filter(o => o.fulfillment_status !== "fulfilled");
if (pending.length === 0) { setManifestLoading(false); return; }
const html = await fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders: pending }),
}).then(r => r.text());
openHtmlInPopup(html);
setManifestLoading(false);
}}
disabled={manifestLoading}
isLoading={manifestLoading}
>
{manifestLoading ? "Generating..." : "Generate Manifest"}
</AdminButton>
</div>
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left w-10">
<input id="fld-search" type="checkbox" checked={selected.size === filtered.length && filtered.length > 0} onChange={toggleAll} aria-label="Select all orders" className="rounded" />
</th>
<th className="px-5 py-3 font-semibold text-left">Invoice</th>
<th className="px-5 py-3 font-semibold text-left">Customer</th>
<th className="px-5 py-3 font-semibold text-left">Pickup Date</th>
<th className="px-5 py-3 font-semibold text-right">Subtotal</th>
<th className="px-5 py-3 font-semibold text-right">Deposit</th>
<th className="px-5 py-3 font-semibold text-right">Balance</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Payment</th>
<th className="px-5 py-3 font-semibold text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? (
<tr><td colSpan={10} className="py-8 text-center text-[var(--admin-text-muted)]">No orders match the current filters.</td></tr>
) : filtered.map(o => (
<tr key={o.id} className={`hover:bg-[var(--admin-bg-subtle)] ${selected.has(o.id) ? "bg-[var(--admin-accent-light)]" : ""}`}>
<td className="px-5 py-3">
<input type="checkbox" checked={selected.has(o.id)} onChange={() => toggleSelect(o.id)} aria-label={`Select order ${o.invoice_number ?? o.id}`} className="rounded" />
</td>
<td className="px-5 py-3 font-mono text-xs text-[var(--admin-text-muted)]">{o.invoice_number ?? "—"}</td>
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{o.company_name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{o.anticipated_pickup_date ?? "—"}</td>
<td className="px-5 py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(o.subtotal).toFixed(2)}</td>
<td className="px-5 py-3 text-right text-[var(--admin-text-secondary)]">${Number(o.deposit_paid).toFixed(2)} / ${Number(o.deposit_required).toFixed(2)}</td>
<td className="px-5 py-3 text-right">
<span className={Number(o.balance_due) > 0 ? "text-[var(--admin-warning)] font-medium" : "text-[var(--admin-accent)]"}>
${Number(o.balance_due).toFixed(2)}
</span>
</td>
<td className="px-5 py-3"><StatusBadge status={o.status} /></td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${o.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"}`}>
{o.payment_status}
</span>
</td>
<td className="px-5 py-4 actions-cell relative">
<div className="flex items-center gap-2">
{/* Primary: Mark Fulfilled — only when order is not yet fulfilled */}
{o.fulfillment_status !== "fulfilled" && (
<AdminButton
variant="primary"
size="sm"
onClick={() => !fulfilling && handleFulfill(o.id)}
disabled={fulfilling === o.id}
isLoading={fulfilling === o.id}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
{fulfilling === o.id ? "..." : "Fulfill"}
</AdminButton>
)}
{/* Primary: Record Deposit — when there's a balance due */}
{Number(o.balance_due) > 0 && o.fulfillment_status !== "fulfilled" && (
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowDepForm(o.id)}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-9-9h18" /></svg>
Deposit
</AdminButton>
)}
{/* Invoice download — always visible as icon button */}
<a aria-label="Download Invoice"
href={`/api/wholesale/invoice/${o.id}/pdf`}
target="_blank"
rel="noopener noreferrer" title="Download Invoice"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
</a>
{/* ⋮ Actions dropdown — opens upward to avoid table cutoff */}
<div className="relative">
<button type="button"
onClick={(e) => toggleActions(o.id, e)}
title="More actions"
aria-label={`More actions for order ${o.invoice_number ?? o.id}`}
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{openActions === o.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { setOpenActions(null); setShowViewOrder(o); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
>
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
View / Edit Order
</button>
<button type="button"
onClick={() => {
setOpenActions(null);
fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders: [o] }),
})
.then((r) => r.text())
.then((html) => openHtmlInPopup(html));
}}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 01-18 0 9 9 0 0118 0z"/></svg>
Generate Manifest
</button>
<button type="button"
onClick={() => {
setOpenActions(null);
fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerIds: [o.customer_id], brandId }),
}).then(r => r.json()).then(d => onMsg("success", `Price sheet sent to customer.`)).catch(() => onMsg("error", "Failed to send price sheet."));
}}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Send Price Sheet
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
{o.status !== "cancelled" && o.fulfillment_status !== "fulfilled" && (
<button type="button"
onClick={() => { setOpenActions(null); handleCancelOrder(o.id); }}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
Cancel Order
</button>
)}
{o.fulfillment_status !== "fulfilled" && (
<button type="button"
onClick={() => { setOpenActions(null); handleDeleteOrder(o.id); }}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete Order
</button>
)}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Deposit modal */}
{showDepForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Record Deposit</h3>
<div className="space-y-3">
<div>
<label htmlFor="fld-4-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount ($)</label>
<input id="fld-4-amount" type="number" value={depAmount} onChange={e => setDepAmount(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" />
</div>
<div>
<label htmlFor="fld-5-method" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
<select id="fld-5-method" value={depMethod} onChange={e => setDepMethod(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)]">
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="wire">Wire</option>
<option value="card">Card</option>
</select>
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={() => handleRecordDeposit(showDepForm)} variant="primary">
Record Deposit
</AdminButton>
<AdminButton onClick={() => setShowDepForm(null)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
</div>
)}
{/* Bulk Deposit modal */}
{showBulkDep && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Bulk Record Deposit ({selected.size} orders)</h3>
<div className="space-y-3">
<div>
<label htmlFor="fld-6-amount-per-order" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount per order ($)</label>
<input id="fld-6-amount-per-order" type="number" value={bulkDepAmount} onChange={e => setBulkDepAmount(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="0.00" />
</div>
<div>
<label htmlFor="fld-7-method-2" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
<select id="fld-7-method-2" value={bulkDepMethod} onChange={e => setBulkDepMethod(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)]">
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="wire">Wire</option>
<option value="card">Card</option>
</select>
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleBulkDeposit} variant="primary" disabled={!bulkDepAmount || bulkLoading} isLoading={bulkLoading}>
{bulkLoading ? "Recording..." : "Record on All"}
</AdminButton>
<AdminButton onClick={() => setShowBulkDep(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
</div>
)}
{/* Order Details modal */}
{showViewOrder && (
<OrderDetailsModal
order={showViewOrder}
onClose={() => setShowViewOrder(null)}
onFulfill={handleFulfill}
onRecordDeposit={(id) => { setShowViewOrder(null); setShowDepForm(id); }}
fulfilling={fulfilling}
/>
)}
</div>
);
}
@@ -1,101 +0,0 @@
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
interface PriceSheetModalProps {
customerCount: number;
defaultSubject: string;
onConfirm: (subject: string, customNote: string) => void;
onClose: () => void;
}
// Modal that confirms a price-sheet send — gathers subject + optional note,
// then delegates to onConfirm.
export default function PriceSheetModal({
customerCount,
defaultSubject,
onConfirm,
onClose,
}: PriceSheetModalProps) {
const [subject, setSubject] = useState(defaultSubject);
const [note, setNote] = useState("");
const [sending, setSending] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!subject.trim()) return;
setSending(true);
onConfirm(subject.trim(), note.trim());
}
return (
<div
role="dialog"
aria-modal="true"
aria-label="Send price sheet"
tabIndex={-1}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{customerCount === 1 ? "1 customer" : `${customerCount} customers`}
</p>
</div>
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-5 space-y-4">
<div>
<label htmlFor="ws-price-subject" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label>
<input
id="ws-price-subject"
value={subject}
onChange={e => setSubject(e.target.value)}
required
aria-required="true"
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="ws-price-note" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">
Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label>
<textarea
id="ws-price-note"
value={note}
onChange={e => setNote(e.target.value)}
rows={4}
placeholder="e.g. 'Check out our new seasonal items on page 2. Place your order by Friday for weekend pickup.'"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] resize-none"
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This note will appear at the top of the email, above the product table.
</p>
</div>
</div>
<div className="px-6 py-4 border-t border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)] rounded-b-2xl">
<div className="text-sm text-[var(--admin-text-muted)]">
{customerCount === 1 ? "1 customer" : `${customerCount} customers`} will receive this email.
</div>
<div className="flex gap-3">
<AdminButton type="button" variant="secondary" onClick={onClose}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={sending || !subject.trim()} isLoading={sending}>
{sending ? "Sending..." : `Send to ${customerCount === 1 ? "1 Customer" : `${customerCount} Customers`}`}
</AdminButton>
</div>
</div>
</form>
</div>
</div>
);
}
@@ -1,262 +0,0 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
import {
type WholesaleProduct,
deleteWholesaleProduct,
saveWholesaleProduct,
} from "@/actions/wholesale";
import type { MsgFn } from "./types";
interface ProductsTabProps {
products: WholesaleProduct[];
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
}
// Wholesale product catalog management. Create/edit/delete products and their price tiers.
export default function ProductsTab({ products, brandId, onMsg, onRefresh }: ProductsTabProps) {
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<WholesaleProduct | null>(null);
const [form, setForm] = useState({
name: "", description: "", unitType: "each", availability: "available",
qtyAvailable: 0, priceTiers: "", hpSku: "", hpItemId: "",
handlingInstructions: "", storageWarning: "", productLabel: "",
});
const [saving, setSaving] = useState(false);
const [openProductActions, setOpenProductActions] = useState<string | null>(null);
const [deletingProduct, setDeletingProduct] = useState<string | null>(null);
// Close product actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".product-actions-cell")) {
setOpenProductActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleProductActions(productId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenProductActions(prev => prev === productId ? null : productId);
}
async function handleDeleteProduct(productId: string) {
if (!confirm("Delete this product? Products attached to order items cannot be deleted. Set availability to unavailable instead.")) return;
setDeletingProduct(productId);
const result = await deleteWholesaleProduct(productId);
setDeletingProduct(null);
if (result.success) {
onMsg("success", "Product deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete product.");
}
}
async function handleSave() {
setSaving(true);
let tiers: Array<{ min_qty: number; max_qty: number; price: number }> = [];
try { tiers = JSON.parse(form.priceTiers || "[]"); } catch {}
const result = await saveWholesaleProduct({
brandId,
id: editing?.id,
name: form.name,
description: form.description || undefined,
unitType: form.unitType,
availability: form.availability,
qtyAvailable: form.qtyAvailable,
priceTiers: tiers,
hpSku: form.hpSku || undefined,
hpItemId: form.hpItemId || undefined,
handlingInstructions: form.handlingInstructions || undefined,
storageWarning: form.storageWarning || undefined,
productLabel: form.productLabel || undefined,
});
setSaving(false);
if (result.success) {
onMsg("success", editing ? "Product updated." : "Product created.");
setShowForm(false);
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
function openNew() {
setEditing(null);
setForm({ name: "", description: "", unitType: "each", availability: "available", qtyAvailable: 0, priceTiers: "", hpSku: "", hpItemId: "", handlingInstructions: "", storageWarning: "", productLabel: "" });
setShowForm(true);
}
function openEdit(p: WholesaleProduct) {
setEditing(p);
setForm({
name: p.name,
description: p.description ?? "",
unitType: p.unit_type,
availability: p.availability,
qtyAvailable: Number(p.qty_available),
priceTiers: JSON.stringify(p.price_tiers),
hpSku: p.hp_sku ?? "",
hpItemId: p.hp_item_id ?? "",
handlingInstructions: p.handling_instructions ?? "",
storageWarning: p.storage_warning ?? "",
productLabel: p.product_label ?? "",
});
setShowForm(true);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Products ({products.length})</h2>
<AdminButton variant="primary" size="sm" onClick={openNew}>
+ Add Product
</AdminButton>
</div>
{showForm && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
<input id="ws-prod-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: 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="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
<textarea id="ws-prod-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: 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="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
<select id="ws-prod-unit" value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: 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)]">
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div>
<label htmlFor="fld-1-availability" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Availability</label>
<select id="fld-1-availability" value={form.availability} onChange={e => setForm(f => ({ ...f, availability: 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)]">
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
<option value="coming_soon">Coming Soon</option>
</select>
</div>
<div>
<label htmlFor="fld-2-qty-available" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Qty Available</label>
<input id="fld-2-qty-available" type="number" value={form.qtyAvailable} onChange={e => setForm(f => ({ ...f, qtyAvailable: Number(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-3-hp-sku" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">HP SKU</label>
<input id="fld-3-hp-sku" value={form.hpSku} onChange={e => setForm(f => ({ ...f, hpSku: 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-4-price-tiers-json-array-of-min-qty1max-qty24price20" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Price Tiers (JSON array of {"{\"min_qty\":1,\"max_qty\":24,\"price\":20}"})</label>
<input id="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" value={form.priceTiers} onChange={e => setForm(f => ({ ...f, priceTiers: e.target.value }))}
placeholder='[{"min_qty":1,"max_qty":24,"price":20},{"min_qty":25,"max_qty":0,"price":18}]'
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm font-mono outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
{saving ? "Saving..." : "Save Product"}
</AdminButton>
<AdminButton onClick={() => setShowForm(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
)}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Product</th>
<th className="px-5 py-3 font-semibold text-left">Unit</th>
<th className="px-5 py-3 font-semibold text-left">Availability</th>
<th className="px-5 py-3 font-semibold text-left">In Stock</th>
<th className="px-5 py-3 font-semibold text-left">HP SKU</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No wholesale products yet</p>
<p className="text-xs text-slate-400">Add products to your wholesale catalog to get started.</p>
</div>
</td>
</tr>
) : products.map(p => (
<tr key={p.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.unit_type}</td>
<td className="px-5 py-3">
<AdminBadge variant={p.availability === "available" ? "success" : p.availability === "coming_soon" ? "warning" : "default"}>
{p.availability.replace("_", " ")}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.qty_available}</td>
<td className="px-5 py-4 font-mono text-xs text-[var(--admin-text-muted)]">{p.hp_sku ?? "—"}</td>
<td className="px-5 py-4 product-actions-cell relative">
<div className="flex items-center gap-1.5">
<div className="relative">
<button type="button"
onClick={(e) => toggleProductActions(p.id, e)}
title="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
aria-label="More actions">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{openProductActions === p.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-48 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { setOpenProductActions(null); openEdit(p); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
aria-label="Edit Product">
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Product
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button type="button"
onClick={() => { setOpenProductActions(null); handleDeleteProduct(p.id); }}
disabled={deletingProduct === p.id}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{deletingProduct === p.id ? "Deleting..." : "Delete Product"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,287 +0,0 @@
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&apos;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>
);
}
@@ -1,25 +0,0 @@
import { AdminBadge } from "@/components/admin/design-system";
// Maps wholesale order status strings to AdminBadge variants + labels.
// Falls back to the raw status for unknown values.
const VARIANT_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
pending: "warning",
awaiting_deposit: "info",
confirmed: "info",
fulfilled: "success",
};
const LABEL_MAP: Record<string, string> = {
pending: "Pending",
awaiting_deposit: "Awaiting Deposit",
confirmed: "Confirmed",
fulfilled: "Fulfilled",
};
export default function StatusBadge({ status }: { status: string }) {
return (
<AdminBadge variant={VARIANT_MAP[status] ?? "default"}>
{LABEL_MAP[status] ?? status}
</AdminBadge>
);
}
@@ -1,165 +0,0 @@
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 type="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 htmlFor="fld-1-webhook-url" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Webhook URL</label>
<input id="fld-1-webhook-url"
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)]"
aria-label="https://your-system.com/webhooks/wholesale"/>
</div>
<div>
<label htmlFor="fld-2-signing-secret-hmac-sha256" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Signing Secret (HMAC-SHA256)</label>
<input id="fld-2-signing-secret-hmac-sha256"
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"
aria-label="Leave blank to keep current secret"/>
<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>
);
}
@@ -1,9 +0,0 @@
// SVG icon for the Wholesale Portal header. Defined at module level so it
// isn't recreated on every render of the parent.
export default function WholesaleIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
}
@@ -1,47 +0,0 @@
// Skeleton shown by the shell while initial data loads.
export default function WholesaleLoadingSkeleton() {
return (
<div className="space-y-6 px-6 py-6">
{/* Header skeleton */}
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-slate-200 animate-pulse" />
<div className="space-y-2">
<div className="h-6 w-40 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-64 bg-slate-100 rounded animate-pulse" />
</div>
</div>
{/* Tab bar skeleton */}
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className={`h-10 rounded-xl bg-slate-200 animate-pulse ${i === 1 ? "w-24" : "w-20"}`} />
))}
</div>
{/* Stat cards skeleton */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="rounded-xl border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-3 w-20 bg-slate-100 rounded mb-2" />
<div className="h-7 w-16 bg-slate-200 rounded" />
</div>
))}
</div>
{/* Recent orders skeleton */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="h-5 w-28 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="space-y-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-slate-100 last:border-0 animate-pulse">
<div className="h-4 w-20 bg-slate-100 rounded" />
<div className="h-4 w-32 bg-slate-100 rounded flex-1" />
<div className="h-4 w-24 bg-slate-100 rounded hidden md:block" />
<div className="h-5 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
// Shared types used across the wholesale admin client module.
// Lives here so each tab/panel can import them without depending on the shell.
export type MsgKind = "success" | "error";
/** Toast-style message callback passed down from the shell to every tab/panel. */
export type MsgFn = (kind: MsgKind, text: string) => void;
/** Pending wholesale-customer registration row used by CustomersTab. */
export interface PendingRegistration {
id: string;
company_name: string | null;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
created_at: string;
}
/** Compact row used on the Dashboard's webhook-activity table. */
export interface WebhookActivityEntry {
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}
-37
View File
@@ -1,37 +0,0 @@
export const brands = {
tuxedo: {
name: "Tuxedo Corn",
colors: {
background: "bg-yellow-50",
primary: "bg-slate-900",
buttonText: "text-white",
},
messaging: {
heroTitle:
"Fresh Olathe Sweet Corn Delivered Near You",
heroDescription:
"Find a stop, preorder corn, or ship cooler boxes directly to your home.",
},
},
ird: {
name: "Indian River Direct",
colors: {
background: "bg-orange-50",
primary: "bg-orange-600",
buttonText: "text-white",
},
messaging: {
heroTitle:
"Premium Citrus Delivered Direct to Your Community",
heroDescription:
"Enter your ZIP code to find upcoming delivery stops near you.",
},
},
};
-76
View File
@@ -1,76 +0,0 @@
/**
* Service-layer admin user creation. Hits the `admin_users` table directly
* via the shared pg pool. Returns the inserted row (or existing row if the
* user was already provisioned).
*/
export async function createAdminUser(
userId: string,
role: string,
brandId: string | null
): Promise<Record<string, unknown> | null> {
const { pool } = await import("@/lib/db");
const body = {
user_id: userId,
role,
brand_id: brandId,
active: true,
can_manage_products: role === "platform_admin",
can_manage_stops: role === "platform_admin",
can_manage_orders: true,
can_manage_pickup: role !== "store_employee",
can_manage_messages: role === "platform_admin",
can_manage_refunds: role === "platform_admin",
can_manage_users: role === "platform_admin",
can_manage_water_log: role === "platform_admin",
can_manage_reports: role === "platform_admin",
can_manage_settings: role === "platform_admin",
must_change_password: false,
};
try {
const { rows } = await pool.query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, role, brand_id, active,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
can_manage_settings, must_change_password)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (user_id) DO UPDATE
SET role = EXCLUDED.role,
brand_id = EXCLUDED.brand_id,
active = EXCLUDED.active,
can_manage_products = EXCLUDED.can_manage_products,
can_manage_stops = EXCLUDED.can_manage_stops,
can_manage_orders = EXCLUDED.can_manage_orders,
can_manage_pickup = EXCLUDED.can_manage_pickup,
can_manage_messages = EXCLUDED.can_manage_messages,
can_manage_refunds = EXCLUDED.can_manage_refunds,
can_manage_users = EXCLUDED.can_manage_users,
can_manage_water_log = EXCLUDED.can_manage_water_log,
can_manage_reports = EXCLUDED.can_manage_reports,
can_manage_settings = EXCLUDED.can_manage_settings
RETURNING *`,
[
body.user_id,
body.role,
body.brand_id,
body.active,
body.can_manage_products,
body.can_manage_stops,
body.can_manage_orders,
body.can_manage_pickup,
body.can_manage_messages,
body.can_manage_refunds,
body.can_manage_users,
body.can_manage_water_log,
body.can_manage_reports,
body.can_manage_settings,
body.must_change_password,
],
);
return rows[0] ?? null;
} catch {
return null;
}
}
-19
View File
@@ -1,19 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import type { AdminUser } from "@/lib/admin-permissions-types";
export type AuthGuardResult =
| { authorized: true; adminUser: AdminUser }
| { authorized: false; adminUser: null };
/**
* Require an authenticated admin user. Returns a discriminated result so
* callers can branch without throwing. Use this in API route handlers that
* need a uniform "is this request authenticated?" check before proceeding.
*/
export async function requireAdminUser(): Promise<AuthGuardResult> {
const adminUser = await getAdminUser();
if (!adminUser) {
return { authorized: false, adminUser: null };
}
return { authorized: true, adminUser };
}
-308
View File
@@ -1,308 +0,0 @@
// Route Commerce Platform — Billing Logic
// Central helpers for subscription creation, status sync, and feature management
import "server-only";
import Stripe from "stripe";
import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
import { pool } from "@/lib/db";
// ── Subscription status types ──────────────────────────────────────────────────
export type SubscriptionStatus =
| "active"
| "past_due"
| "canceled"
| "trialing"
| "incomplete"
| "unpaid";
export interface BrandSubscription {
brand_id: string;
stripe_subscription_id: string | null;
stripe_subscription_status: SubscriptionStatus | null;
stripe_current_period_end: string | null;
stripe_customer_id: string | null;
plan_tier: PlanTierKey;
}
// ── RPC call helper (raw SQL via pg pool) ─────────────────────────────────────
async function rpc<T = Record<string, unknown>>(
fn: string,
params: ReadonlyArray<unknown>
): Promise<T> {
const placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
const { rows } = await pool.query<Record<string, unknown>>(
`SELECT * FROM ${fn}(${placeholders})`,
params as unknown[]
);
return rows as unknown as T;
}
// ── Feature sync ─────────────────────────────────────────────────────────────
// When a subscription changes, sync the plan tier + all add-on feature flags
export async function syncSubscriptionFeatures(
brandId: string,
subscriptionItems: Array<{ priceId: string; enabled: boolean }>
): Promise<void> {
// Determine plan tier from subscription items
const priceToTier: Record<string, PlanTierKey> = {
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
};
const priceToAddon: Record<string, AddonKey> = {
[process.env.STRIPE_PRICE_HARVEST_REACH ?? ""]: "harvest_reach",
[process.env.STRIPE_PRICE_WHOLESALE_PORTAL ?? ""]: "wholesale_portal",
[process.env.STRIPE_PRICE_WATER_LOG ?? ""]: "water_log",
[process.env.STRIPE_PRICE_AI_TOOLS ?? ""]: "ai_tools",
[process.env.STRIPE_PRICE_SQUARE_SYNC ?? ""]: "square_sync",
[process.env.STRIPE_PRICE_SMS_CAMPAIGNS ?? ""]: "sms_campaigns",
};
// Update plan tier
const tierItem = subscriptionItems.find((item) => priceToTier[item.priceId]);
if (tierItem) {
const newTier = priceToTier[tierItem.priceId];
if (newTier) {
await rpc("update_brand_plan_tier", [brandId, newTier]);
}
}
// Sync add-on features (parallelize per-addon RPC calls)
await Promise.all(
Object.entries(priceToAddon).map(async ([priceId, addonKey]) => {
if (!priceId) return;
try {
const item = subscriptionItems.find((i) => i.priceId === priceId);
const enabled = item?.enabled ?? false;
await rpc("set_brand_feature", [brandId, addonKey, enabled]);
} catch (err) {
// One add-on RPC failure must not block the others from syncing.
console.warn("[billing] set_brand_feature failed for", addonKey, err);
}
})
);
}
// ── Subscription creation ─────────────────────────────────────────────────────
export async function createOrUpdateSubscription(
brandId: string,
priceKey: string,
billingCycle: "monthly" | "annual"
): Promise<{ subscriptionId: string; clientSecret: string }> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" });
// Get brand's Stripe customer
const brandRows = await rpc<Array<BrandSubscription>>(
"get_brand_subscription",
[brandId]
);
const brandData = brandRows[0];
const customerId = brandData?.stripe_customer_id;
if (!customerId) throw new Error("No Stripe customer for brand. Complete Stripe setup first.");
// Get price ID
const priceId = getPriceId(priceKey, billingCycle);
if (!priceId) throw new Error(`No price configured for ${priceKey} (${billingCycle})`);
// Check if brand already has an active subscription — update it instead of creating new
const existingSubId = brandData?.stripe_subscription_id;
let subscription;
if (existingSubId) {
// Add or update the subscription item
const existing = await stripe.subscriptions.retrieve(existingSubId);
const existingItem = existing.items.data.find((item) => {
const planPrices = [
process.env.STRIPE_PRICE_STARTER ?? "",
process.env.STRIPE_PRICE_FARM ?? "",
process.env.STRIPE_PRICE_ENTERPRISE ?? "",
];
return planPrices.includes(item.price.id);
});
if (existingItem) {
subscription = await stripe.subscriptions.update(existingSubId, {
items: [{ id: existingItem.id, price: priceId }],
proration_behavior: "create_prorations",
});
} else {
subscription = await stripe.subscriptions.update(existingSubId, {
items: [{ price: priceId }],
});
}
} else {
subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: "default_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
expand: ["latest_invoice.payment_intent"],
metadata: { brand_id: brandId, price_key: priceKey, billing: billingCycle },
});
}
// Save subscription ID to brand
const subData = subscription as unknown as { id: string; status: string; current_period_end: number };
await rpc("set_brand_subscription", [
brandId,
subData.id,
subData.status,
new Date(subData.current_period_end * 1000).toISOString(),
]);
const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | null;
const invoiceOrNull = typeof subscription.latest_invoice !== "string" ? latestInvoice : null;
const paymentIntent = invoiceOrNull?.payment_intent as { client_secret?: string } | undefined;
const clientSecret = paymentIntent?.client_secret ?? null;
return { subscriptionId: subscription.id, clientSecret: clientSecret ?? "" };
}
// ── Cancel subscription ─────────────────────────────────────────────────────
export async function cancelBrandSubscription(
brandId: string,
priceKey?: string
): Promise<void> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const brandRows = await rpc<BrandSubscription[]>("get_brand_subscription", [brandId]);
const brand = brandRows[0];
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" });
if (priceKey) {
// Cancel only a specific add-on item, not the whole subscription
const sub = await stripe.subscriptions.retrieve(brand.stripe_subscription_id);
const targetPriceId = getPriceId(priceKey, "monthly");
const item = sub.items.data.find((i) => i.price.id === targetPriceId);
if (item) {
await stripe.subscriptions.update(brand.stripe_subscription_id, {
items: [{ id: item.id, deleted: true }],
proration_behavior: "none",
});
// Disable the feature flag
const addonKey = getAddonKeyFromPriceKey(priceKey);
if (addonKey) {
await rpc("set_brand_feature", [brandId, addonKey, false]);
}
}
} else {
// Cancel entire subscription
await stripe.subscriptions.cancel(brand.stripe_subscription_id);
await rpc("set_brand_subscription", [
brandId,
"",
"canceled",
null,
]);
// Disable all add-on features (parallelize per-addon RPC calls)
const addonKeys = Object.keys(ADDONS) as AddonKey[];
await Promise.all(
addonKeys.map(async (addonKey) => {
try {
await rpc("set_brand_feature", [brandId, addonKey, false]);
} catch (err) {
// One add-on disable failure must not block the others.
console.warn("[billing] disable addon failed for", addonKey, err);
}
})
);
}
}
// ── Past due notification ─────────────────────────────────────────────────────
export async function sendPastDueNotification(brandId: string): Promise<void> {
const brandRows = await rpc<Array<{ name: string }>>("get_brand_subscription", [brandId]);
const brand = brandRows[0];
const brandName = brand?.name ?? "your brand";
const adminEmail = await getAdminEmail(brandId);
await rpc("enqueue_notification", [
brandId,
adminEmail ?? "team@cielohermosa.com",
`Payment Failed — ${brandName}`,
`
<h2>Payment Failed</h2>
<p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p>
<p>Please update your payment method within 7 days to avoid service interruption.</p>
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p>
`,
`Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
]);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function getPriceId(priceKey: string, billingCycle: "monthly" | "annual"): string | null {
if (billingCycle === "annual") {
return process.env[`STRIPE_PRICE_${priceKey.toUpperCase()}_ANNUAL`] ?? null;
}
return process.env[`STRIPE_PRICE_${priceKey.toUpperCase()}`] ?? null;
}
function getAddonKeyFromPriceKey(priceKey: string): AddonKey | null {
const map: Record<string, AddonKey> = {
harvest_reach: "harvest_reach",
wholesale_portal: "wholesale_portal",
water_log: "water_log",
ai_tools: "ai_tools",
square_sync: "square_sync",
sms_campaigns: "sms_campaigns",
};
return map[priceKey] ?? null;
}
async function getAdminEmail(brandId: string): Promise<string | null> {
try {
const data = await rpc<{ notification_email?: string; from_email?: string }>(
"get_wholesale_settings",
[brandId]
);
const settings = Array.isArray(data) ? data[0] : data;
return settings?.notification_email ?? settings?.from_email ?? null;
} catch {
return null;
}
}
// ── Price ID resolution (for webhook) ────────────────────────────────────────
export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "addon"; key: string } | null {
const planMap: Record<string, string> = {
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
};
if (planMap[priceId]) return { type: "plan", key: planMap[priceId] };
const addonMap: Record<string, AddonKey> = {
[process.env.STRIPE_PRICE_HARVEST_REACH ?? ""]: "harvest_reach",
[process.env.STRIPE_PRICE_WHOLESALE_PORTAL ?? ""]: "wholesale_portal",
[process.env.STRIPE_PRICE_WATER_LOG ?? ""]: "water_log",
[process.env.STRIPE_PRICE_AI_TOOLS ?? ""]: "ai_tools",
[process.env.STRIPE_PRICE_SQUARE_SYNC ?? ""]: "square_sync",
[process.env.STRIPE_PRICE_SMS_CAMPAIGNS ?? ""]: "sms_campaigns",
};
if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] };
return null;
}
-146
View File
@@ -1,146 +0,0 @@
// Data service for direct PostgreSQL queries via pg Pool
// This replaces the Supabase client for production use
import { pool } from "./db";
import type { QueryResultRow } from "pg";
// Re-export pool for convenience
export { pool };
// Query helpers for common operations
export async function query<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = []
): Promise<T[]> {
const result = await pool.query<T>(sql, params);
return result.rows;
}
export async function queryOne<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = []
): Promise<T | null> {
const result = await pool.query<T>(sql, params);
return result.rows[0] ?? null;
}
// Brand operations
export async function getBrands() {
return query<{ id: string; name: string; slug: string; accent_color: string | null; active: boolean }>(
"SELECT id, name, slug, accent_color, active FROM brands ORDER BY name"
);
}
export async function getBrandById(brandId: string) {
return queryOne<{ id: string; name: string; slug: string }>(
"SELECT id, name, slug FROM brands WHERE id = $1",
[brandId]
);
}
// Product operations
export async function getProducts(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM products WHERE brand_id = $1 AND active = true AND deleted_at IS NULL ORDER BY name",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM products WHERE active = true AND deleted_at IS NULL ORDER BY name"
);
}
export async function getProductById(productId: string) {
return queryOne<Record<string, unknown>>(
"SELECT * FROM products WHERE id = $1 AND deleted_at IS NULL",
[productId]
);
}
// Stop operations
export async function getStops(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM stops WHERE brand_id = $1 AND active = true ORDER BY date DESC",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM stops WHERE active = true ORDER BY date DESC"
);
}
export async function getStopById(stopId: string) {
return queryOne<Record<string, unknown>>(
"SELECT * FROM stops WHERE id = $1",
[stopId]
);
}
// Order operations
export async function getOrders(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
`SELECT o.*, s.city, s.state, s.date, s.location, s.slug
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.brand_id = $1
ORDER BY o.created_at DESC`,
[brandId]
);
}
return query<Record<string, unknown>>(
`SELECT o.*, s.city, s.state, s.date, s.location, s.slug
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
ORDER BY o.created_at DESC`
);
}
// Worker operations (for time tracking)
export async function getWorkers(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM workers WHERE brand_id = $1 AND is_active = true ORDER BY name",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM workers WHERE is_active = true ORDER BY name"
);
}
// Task operations (for time tracking)
export async function getTasks(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM tasks WHERE brand_id = $1 ORDER BY sort_order",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM tasks ORDER BY sort_order"
);
}
// Customer operations
export async function getCustomers(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM customers WHERE brand_id = $1 ORDER BY name",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM customers ORDER BY name"
);
}
// Brand settings
export async function getBrandSettings(brandId: string) {
return queryOne<Record<string, unknown>>(
"SELECT * FROM brand_settings WHERE brand_id = $1",
[brandId]
);
}
-94
View File
@@ -1,94 +0,0 @@
// Global Error Handler for uncaught exceptions
import { captureError, addBreadcrumb } from "./sentry";
// Handle uncaught errors
if (typeof window !== "undefined") {
window.onerror = (message, source, lineno, colno, error) => {
captureError(error || new Error(String(message)), {
source,
lineno,
colno,
type: "uncaught_error",
});
return false;
};
// Handle unhandled promise rejections
window.onunhandledrejection = (event) => {
captureError(
event.reason instanceof Error
? event.reason
: new Error(String(event.reason)),
{
type: "unhandled_rejection",
promise: event.promise ? String(event.promise) : undefined,
}
);
};
}
// Add breadcrumb for page navigation
export function trackPageNavigation(path: string) {
addBreadcrumb(`Navigated to ${path}`, { path });
}
// Add breadcrumb for user actions
export function trackUserAction(action: string, details?: Record<string, unknown>) {
addBreadcrumb(`User action: ${action}`, { action, ...details });
}
// Performance monitoring
export function measurePerformance(name: string, callback: () => void | Promise<void>) {
const start = performance.now();
const measure = async () => {
await callback();
const duration = performance.now() - start;
addBreadcrumb(`Performance: ${name}`, {
name,
duration: `${duration.toFixed(2)}ms`,
});
};
return measure();
}
// API call tracking
export async function trackApiCall<T>(
endpoint: string,
method: string,
fn: () => Promise<T>
): Promise<T> {
addBreadcrumb(`API call: ${method} ${endpoint}`, { endpoint, method });
try {
const result = await fn();
addBreadcrumb(`API success: ${method} ${endpoint}`, { endpoint, method, success: true });
return result;
} catch (error) {
captureError(error as Error, { endpoint, method });
addBreadcrumb(`API error: ${method} ${endpoint}`, { endpoint, method, success: false });
throw error;
}
}
// Debug logging for development
export const debugLog = {
info: (message: string, data?: unknown) => {
if (process.env.NODE_ENV === "development") {
console.info(`[DEBUG] ${message}`, data);
}
},
warn: (message: string, data?: unknown) => {
if (process.env.NODE_ENV === "development") {
console.warn(`[DEBUG] ${message}`, data);
}
},
error: (message: string, data?: unknown) => {
if (process.env.NODE_ENV === "development") {
console.error(`[DEBUG] ${message}`, data);
}
},
};
-77
View File
@@ -1,77 +0,0 @@
// FedEx OAuth token cache.
// This module intentionally lives OUTSIDE any "use server" file. The
// `react-doctor/server-no-mutable-module-state` rule flags module-scope
// mutable state inside "use server" files because it can leak per-user
// state across requests. The FedEx token cache is NOT per-user state —
// it's a service-credential cache shared across all requests, which is
// the desired behavior. Co-locating the cache in a non-server file keeps
// the rule satisfied while preserving the cross-request token reuse that
// FedEx's rate-limited OAuth endpoint requires.
type FedExAuthToken = {
accessToken: string;
expiresAt: number;
};
const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
export function getFedExApiBaseUrl(useProduction: boolean): string {
return useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
}
// Buffer before expiry to refresh proactively
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
let cachedToken: FedExAuthToken | null = null;
export async function getFedExAuthToken(settings: {
fedexApiKey: string;
fedexApiSecret: string;
useProduction: boolean;
}): Promise<{ accessToken: string } | { error: string }> {
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - EXPIRY_BUFFER_MS) {
return { accessToken: cachedToken.accessToken };
}
const credentials = Buffer.from(
`${settings.fedexApiKey}:${settings.fedexApiSecret}`
).toString("base64");
try {
const res = await fetch(`${base}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${credentials}`,
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
const text = await res.text();
return { error: `FedEx auth failed: ${text}` };
}
const data = (await res.json()) as {
access_token: string;
expires_in: number;
};
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return { accessToken: data.access_token };
} catch (err) {
return { error: `FedEx auth network error: ${(err as Error).message}` };
}
}
// Test-only helper to reset the cache between unit tests.
export function __resetFedExTokenCacheForTests(): void {
cachedToken = null;
}
-89
View File
@@ -1,89 +0,0 @@
/**
* Square OAuth token-exchange helper.
*
* Extracted from the `/api/square/oauth/callback` route so the GET handler
* stays a thin redirect layer — no `fetch(..., { method: "POST" })` or DB
* writes live in the route file. The OAuth `code` is single-use at the
* provider, which makes this write idempotent on replay.
*/
import { pool } from "@/lib/db";
export type SquareTokenExchangeResult =
| { ok: true; accessToken: string; locationId: string | null }
| { ok: false; error: string };
export async function exchangeSquareCodeForToken(args: {
code: string;
origin: string;
}): Promise<SquareTokenExchangeResult> {
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
const appSecret = process.env.SQUARE_APP_SECRET;
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
if (!appId || !appSecret) {
return { ok: false, error: "square_credentials_not_configured" };
}
const tokenUrl =
env === "production"
? "https://connect.squareup.com/v2/oauth2/token"
: "https://connect.squareupsandbox.com/v2/oauth2/token";
const redirectUri = `${args.origin}/api/square/oauth/callback`;
const tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
body: JSON.stringify({
client_id: appId,
client_secret: appSecret,
code: args.code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const tokenData = (await tokenResponse.json().catch(() => ({}))) as {
access_token?: string;
location_id?: string;
};
if (!tokenResponse.ok || !tokenData.access_token) {
return { ok: false, error: "square_token_exchange_failed" };
}
return {
ok: true,
accessToken: tokenData.access_token,
locationId: tokenData.location_id ?? null,
};
}
export async function persistSquareToken(args: {
brandId: string;
accessToken: string;
locationId: string | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await pool.query(
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
args.brandId,
"square",
null,
null,
null,
args.accessToken,
args.locationId,
null,
null,
]
);
return { ok: true };
} catch {
return { ok: false, error: "square_token_save_error" };
}
}
-82
View File
@@ -1,82 +0,0 @@
/**
* Stripe OAuth token-exchange helper.
*
* Extracted from the `/api/stripe/oauth/callback` route so the GET handler
* stays a thin redirect layer — no `fetch(..., { method: "POST" })` or DB
* writes live in the route file. The OAuth `code` is single-use at the
* provider, which makes this write idempotent on replay.
*/
import { savePaymentSettings } from "@/actions/payments";
export type StripeTokenExchangeResult =
| {
ok: true;
accessToken: string;
publishableKey: string | null;
stripeUserId: string;
}
| { ok: false; error: string };
export async function exchangeStripeCodeForToken(args: {
code: string;
}): Promise<StripeTokenExchangeResult> {
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return { ok: false, error: "oauth_not_configured" };
}
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: args.code,
client_id: clientId,
client_secret: clientSecret,
}),
});
const tokenData = (await tokenResponse.json().catch(() => ({}))) as {
error?: string;
error_description?: string;
access_token?: string;
stripe_user_id?: string;
stripe_publishable_key?: string;
};
if (tokenData.error || !tokenData.access_token) {
return {
ok: false,
error: tokenData.error_description || tokenData.error || "exchange_failed",
};
}
return {
ok: true,
accessToken: tokenData.access_token,
publishableKey: tokenData.stripe_publishable_key ?? null,
stripeUserId: tokenData.stripe_user_id ?? "",
};
}
export async function persistStripeCredentials(args: {
brandId: string;
accessToken: string;
publishableKey: string | null;
stripeUserId: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await savePaymentSettings({
brandId: args.brandId,
provider: "stripe",
stripePublishableKey: args.publishableKey ?? undefined,
stripeSecretKey: args.accessToken,
stripeUserId: args.stripeUserId,
});
return result.success
? { ok: true }
: { ok: false, error: result.error || "save_failed" };
}
-59
View File
@@ -1,59 +0,0 @@
/**
* Route Trace data fetching utilities
* Shared data fetching for all route-trace pages
*/
import {
getRouteTraceStats,
getRouteTraceLots,
getHarvestLotsReadyToHaul,
getFieldYieldSummary,
getInventoryByCrop,
getRecentLotEvents,
type RouteTraceStats,
type HaulingLot,
type FieldYieldSummary,
type InventoryByCrop,
type RecentLotEvent,
} from "@/actions/route-trace/lots";
export type RouteTraceData = {
stats: RouteTraceStats;
lots: HaulingLot[];
haulingLots: HaulingLot[];
fieldYield: FieldYieldSummary[];
inventoryByCrop: InventoryByCrop[];
recentActivity: RecentLotEvent[];
};
/**
* Fetch all route-trace data for a brand
*/
export async function fetchRouteTraceData(
brandId: string
): Promise<RouteTraceData> {
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
getRouteTraceStats(brandId),
getRouteTraceLots(brandId),
getHarvestLotsReadyToHaul(brandId),
getFieldYieldSummary(brandId),
getInventoryByCrop(brandId),
getRecentLotEvents(brandId, 10),
]);
return {
stats: statsResult.success ? statsResult.stats : {
active_count: 0,
in_transit_count: 0,
at_shed_count: 0,
total_lots_today: 0,
total_harvested_today: 0,
total_lots: 0,
},
lots: lotsResult.success ? lotsResult.lots : [],
haulingLots: haulingResult.success ? haulingResult.lots : [],
fieldYield: yieldResult.success ? yieldResult.summary : [],
inventoryByCrop: invResult.success ? invResult.inventory : [],
recentActivity: eventsResult.success ? eventsResult.events : [],
};
}
-11
View File
@@ -1,11 +0,0 @@
export const YIELD_UNIT_OPTIONS = [
{ value: "lbs", label: "lbs", abbr: "lbs" },
{ value: "bushel", label: "Bushel", abbr: "bu" },
{ value: "box", label: "Box", abbr: "bx" },
{ value: "sack", label: "Sack", abbr: "sk" },
{ value: "crate", label: "Crate", abbr: "cr" },
{ value: "bin", label: "Bin", abbr: "bn" },
{ value: "pallet", label: "Pallet", abbr: "plt" },
{ value: "custom", label: "Custom", abbr: "" },
] as const;
export type YieldUnit = typeof YIELD_UNIT_OPTIONS[number]["value"];
-40
View File
@@ -1,40 +0,0 @@
"use client";
import DOMPurify from "dompurify";
/**
* Render a sanitized HTML string as React children. Use this anywhere
* `dangerouslySetInnerHTML` would otherwise be the only option (e.g.
* email preview bodies, template bodies, etc.).
*
* DOMPurify is configured with a strict default profile plus a small
* allow-list for the elements commonly used in our email templates
* (inline styles, tables, images with http(s) sources). Custom config
* can be passed via the second argument if needed.
*/
export function SafeHtml({
html,
className,
...rest
}: {
html: string;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
const clean = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_ATTR: [
"href", "target", "rel", "src", "alt", "title", "style", "class",
"id", "name", "colspan", "rowspan", "width", "height", "align",
"valign", "bgcolor", "color", "border", "cellpadding", "cellspacing",
"role", "aria-label", "aria-hidden",
],
});
return (
<div
className={className}
// The sanitizer above guarantees the result is safe to inject.
dangerouslySetInnerHTML={{ __html: clean }}
{...rest}
/>
);
}
-218
View File
@@ -1,218 +0,0 @@
// SEO and Metadata Utilities
import { Metadata } from "next";
// Base URL for the application
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://routecommerce.com";
// Default SEO configuration
export const defaultSEO: Metadata = {
metadataBase: new URL(BASE_URL),
title: {
default: "Route Commerce | Fresh Produce Wholesale Platform",
template: "%s | Route Commerce",
},
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments.",
keywords: [
"wholesale produce",
"farm fresh",
"B2B e-commerce",
"produce distribution",
"pickup stops",
"fresh produce",
"farm management",
"order management",
"customer portal",
"wholesale ordering",
],
authors: [{ name: "Route Commerce" }],
creator: "Route Commerce",
publisher: "Route Commerce",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
type: "website",
locale: "en_US",
url: BASE_URL,
siteName: "Route Commerce",
title: "Route Commerce | Fresh Produce Wholesale Platform",
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Route Commerce Platform",
},
],
},
twitter: {
card: "summary_large_image",
title: "Route Commerce | Fresh Produce Wholesale Platform",
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
site: "@RouteCommerce",
creator: "@RouteCommerce",
images: ["/og-image.png"],
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
],
apple: "/icons/apple-touch-icon.png",
other: [
{
rel: "manifest",
url: "/manifest.json",
},
],
},
alternates: {
canonical: BASE_URL,
languages: {
"en-US": BASE_URL,
},
},
};
// Brand-specific metadata
export function getBrandMetadata(brand: {
name: string;
slug: string;
description?: string;
logo_url?: string;
}) {
return {
title: `${brand.name} | Fresh Produce`,
description: brand.description || `Order fresh produce from ${brand.name}. Pick up at scheduled stops or get deliveries.`,
openGraph: {
title: `${brand.name} | Fresh Produce`,
description: brand.description || `Order fresh produce from ${brand.name}`,
url: `${BASE_URL}/${brand.slug}`,
siteName: brand.name,
images: brand.logo_url ? [{ url: brand.logo_url }] : [],
},
};
}
// Product metadata
export function getProductMetadata(product: {
name: string;
description?: string;
price?: number;
image_url?: string;
}) {
return {
title: `${product.name} | Route Commerce`,
description: product.description || `Order ${product.name} from Route Commerce`,
openGraph: {
title: product.name,
description: product.description,
images: product.image_url ? [{ url: product.image_url }] : [],
},
};
}
// Admin page metadata
export function getAdminMetadata(pageTitle: string) {
return {
title: `${pageTitle} | Admin | Route Commerce`,
robots: {
index: false,
follow: false,
},
};
}
// Structured data (JSON-LD)
export function getOrganizationSchema() {
return {
"@context": "https://schema.org",
"@type": "Organization",
name: "Route Commerce",
url: BASE_URL,
logo: `${BASE_URL}/logo.svg`,
sameAs: [
"https://twitter.com/RouteCommerce",
"https://linkedin.com/company/route-commerce",
],
contactPoint: {
"@type": "ContactPoint",
contactType: "customer service",
email: "support@routecommerce.com",
},
};
}
export function getProductSchema(product: {
name: string;
description: string;
price: number;
currency?: string;
availability?: string;
image?: string;
brand?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.image,
brand: {
"@type": "Brand",
name: product.brand || "Route Commerce",
},
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: product.currency || "USD",
availability: product.availability || "https://schema.org/InStock",
},
};
}
export function getLocalBusinessSchema(business: {
name: string;
address: string;
city: string;
state: string;
postalCode: string;
phone?: string;
email?: string;
openingHours?: string[];
}) {
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: business.name,
address: {
"@type": "PostalAddress",
streetAddress: business.address,
addressLocality: business.city,
addressRegion: business.state,
postalCode: business.postalCode,
},
telephone: business.phone,
email: business.email,
openingHours: business.openingHours,
};
}
// Generate sitemap data
export function generateSitemap(pages: { url: string; lastModified?: Date; changeFrequency?: string; priority?: number }[]) {
return pages.map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified: page.lastModified || new Date(),
changeFrequency: page.changeFrequency || "weekly",
priority: page.priority || 0.5,
}));
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Safe service-headers for Vercel Edge Runtime.
*
* Supabase REST accepts `apikey` as a standalone auth header no
* Authorization: Bearer needed. This avoids all JWT header validation
* issues on Vercel Edge Runtime where +, /, = cause "Headers.append:
* ...is an invalid header value".
*
* The apikey header alone is sufficient for service-role access to
* the Supabase REST API.
*/
export function svcHeaders(serviceKey: string): Record<string, string> {
return {
apikey: serviceKey,
};
}
-337
View File
@@ -1,337 +0,0 @@
// Zod schemas for API validation across the application
import { z } from "zod";
// Common validation patterns
const uuidSchema = z.uuid();
const emailSchema = z.email();
const phoneSchema = z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number");
const urlSchema = z.url().optional();
// Pagination
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
// Brand/Admin User
export const brandIdSchema = z.object({
brand_id: z.uuid().optional(),
});
// Orders
export const createOrderSchema = z.object({
brand_id: uuidSchema,
customer_email: emailSchema.optional(),
customer_name: z.string().min(1).max(255).optional(),
customer_phone: phoneSchema.optional(),
customer_address: z.string().optional(),
notes: z.string().max(1000).optional(),
items: z.array(z.object({
product_id: uuidSchema,
quantity: z.number().int().positive(),
price: z.number().positive(),
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
})).min(1),
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
stop_id: uuidSchema.optional(),
});
export const updateOrderSchema = z.object({
order_id: uuidSchema,
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
notes: z.string().max(1000).optional(),
tracking_number: z.string().optional(),
payment_status: z.enum(["pending", "paid", "refunded", "failed"]).optional(),
});
export const orderFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
date_from: z.iso.datetime().optional(),
date_to: z.iso.datetime().optional(),
customer_email: emailSchema.optional(),
stop_id: uuidSchema.optional(),
});
// Products
export const createProductSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
price: z.number().positive(),
unit: z.string().min(1).max(50).optional(),
category: z.string().max(100).optional(),
sku: z.string().max(100).optional(),
is_active: z.boolean().default(true),
is_taxable: z.boolean().default(true),
inventory_quantity: z.number().int().nonnegative().optional(),
image_url: urlSchema.optional(),
});
export const updateProductSchema = z.object({
product_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional(),
price: z.number().positive().optional(),
unit: z.string().min(1).max(50).optional(),
category: z.string().max(100).optional(),
sku: z.string().max(100).optional(),
is_active: z.boolean().optional(),
is_taxable: z.boolean().optional(),
inventory_quantity: z.number().int().nonnegative().optional(),
image_url: urlSchema.optional(),
});
export const productFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
category: z.string().optional(),
is_active: z.boolean().optional(),
search: z.string().optional(),
min_price: z.number().positive().optional(),
max_price: z.number().positive().optional(),
});
// Stops
export const createStopSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
address: z.string().min(1),
city: z.string().max(100).optional(),
state: z.string().max(50).optional(),
postal_code: z.string().max(20).optional(),
country: z.string().max(100).optional(),
scheduled_at: z.iso.datetime(),
notes: z.string().max(1000).optional(),
product_ids: z.array(uuidSchema).optional(),
});
export const updateStopSchema = z.object({
stop_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
address: z.string().min(1).optional(),
city: z.string().max(100).optional(),
state: z.string().max(50).optional(),
postal_code: z.string().max(20).optional(),
scheduled_at: z.iso.datetime().optional(),
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
notes: z.string().max(1000).optional(),
product_ids: z.array(uuidSchema).optional(),
});
export const stopFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
date_from: z.iso.datetime().optional(),
date_to: z.iso.datetime().optional(),
});
// Communication Campaigns
export const createCampaignSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
subject: z.string().min(1).max(500),
template_id: uuidSchema.optional(),
content: z.string().min(1),
type: z.enum(["email", "sms"]).default("email"),
segment_id: uuidSchema.optional(),
contact_ids: z.array(uuidSchema).optional(),
scheduled_at: z.iso.datetime().optional(),
});
export const updateCampaignSchema = z.object({
campaign_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1).optional(),
status: z.enum(["draft", "scheduled", "sending", "sent", "cancelled"]).optional(),
scheduled_at: z.iso.datetime().optional(),
});
// Communication Contacts
export const createContactSchema = z.object({
brand_id: uuidSchema,
email: emailSchema,
phone: phoneSchema.optional(),
first_name: z.string().max(100).optional(),
last_name: z.string().max(100).optional(),
company: z.string().max(255).optional(),
tags: z.array(z.string().max(50)).optional(),
email_opt_in: z.boolean().default(true),
sms_opt_in: z.boolean().default(false),
});
export const updateContactSchema = z.object({
contact_id: uuidSchema,
email: emailSchema.optional(),
phone: phoneSchema.optional(),
first_name: z.string().max(100).optional(),
last_name: z.string().max(100).optional(),
company: z.string().max(255).optional(),
tags: z.array(z.string().max(50)).optional(),
email_opt_in: z.boolean().optional(),
sms_opt_in: z.boolean().optional(),
notes: z.string().max(2000).optional(),
});
// Communication Templates
export const createTemplateSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1),
type: z.enum(["email", "sms"]).default("email"),
category: z.string().max(100).optional(),
});
export const updateTemplateSchema = z.object({
template_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1).optional(),
category: z.string().max(100).optional(),
is_active: z.boolean().optional(),
});
// Water Log
export const createWaterLogSchema = z.object({
brand_id: uuidSchema,
field_id: uuidSchema.optional(),
field_name: z.string().max(255).optional(),
gallons: z.number().positive(),
duration_minutes: z.number().int().nonnegative().optional(),
notes: z.string().max(500).optional(),
logged_at: z.iso.datetime().optional(),
});
export const updateWaterLogSchema = z.object({
log_id: uuidSchema,
gallons: z.number().positive().optional(),
duration_minutes: z.number().int().nonnegative().optional(),
notes: z.string().max(500).optional(),
});
export const waterLogFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
field_id: uuidSchema.optional(),
date_from: z.iso.datetime().optional(),
date_to: z.iso.datetime().optional(),
});
// Wholesale
export const createWholesaleOrderSchema = z.object({
brand_id: uuidSchema,
customer_id: uuidSchema,
items: z.array(z.object({
product_id: uuidSchema,
quantity: z.number().int().positive(),
price: z.number().positive(),
})).min(1),
pickup_stop_id: uuidSchema.optional(),
notes: z.string().max(1000).optional(),
deposit_amount: z.number().nonnegative().optional(),
payment_method: z.enum(["stripe", "square", "invoice"]).default("invoice"),
});
export const updateWholesaleCustomerSchema = z.object({
customer_id: uuidSchema,
company_name: z.string().max(255).optional(),
contact_name: z.string().max(255).optional(),
email: emailSchema.optional(),
phone: phoneSchema.optional(),
credit_limit: z.number().nonnegative().optional(),
payment_terms: z.enum(["prepay", "net15", "net30", "net60"]).optional(),
is_active: z.boolean().optional(),
});
// Billing / Subscriptions
export const createSubscriptionSchema = z.object({
brand_id: uuidSchema,
plan_tier: z.enum(["starter", "farm", "enterprise"]),
interval: z.enum(["monthly", "annual"]).default("monthly"),
payment_method_id: z.string().optional(),
});
export const updateSubscriptionSchema = z.object({
subscription_id: z.string(),
plan_tier: z.enum(["starter", "farm", "enterprise"]).optional(),
interval: z.enum(["monthly", "annual"]).optional(),
status: z.enum(["active", "cancelled", "past_due", "trialing"]).optional(),
});
// Admin Users
export const createAdminUserSchema = z.object({
email: emailSchema,
role: z.enum(["platform_admin", "brand_admin", "store_employee"]),
brand_id: uuidSchema.optional(),
can_manage_products: z.boolean().default(false),
can_manage_stops: z.boolean().default(false),
can_manage_orders: z.boolean().default(false),
can_manage_pickup: z.boolean().default(false),
can_manage_messages: z.boolean().default(false),
can_manage_refunds: z.boolean().default(false),
can_manage_users: z.boolean().default(false),
can_manage_water_log: z.boolean().default(false),
can_manage_reports: z.boolean().default(false),
can_manage_settings: z.boolean().default(false),
});
export const updateAdminUserSchema = z.object({
user_id: uuidSchema,
role: z.enum(["platform_admin", "brand_admin", "store_employee"]).optional(),
can_manage_products: z.boolean().optional(),
can_manage_stops: z.boolean().optional(),
can_manage_orders: z.boolean().optional(),
can_manage_pickup: z.boolean().optional(),
can_manage_messages: z.boolean().optional(),
can_manage_refunds: z.boolean().optional(),
can_manage_users: z.boolean().optional(),
can_manage_water_log: z.boolean().optional(),
can_manage_reports: z.boolean().optional(),
can_manage_settings: z.boolean().optional(),
active: z.boolean().optional(),
});
// Reports
export const reportFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
start_date: z.iso.datetime(),
end_date: z.iso.datetime(),
group_by: z.enum(["day", "week", "month"]).default("day"),
});
// Feature Flags
export const featureToggleSchema = z.object({
brand_id: uuidSchema,
feature_key: z.string().min(1),
enabled: z.boolean(),
});
// Referral
export const referralSchema = z.object({
referrer_id: uuidSchema,
referral_code: z.string().min(6).max(50),
referred_email: emailSchema,
campaign_id: z.string().optional(),
});
// Cart
export const cartItemSchema = z.object({
product_id: uuidSchema,
quantity: z.number().int().positive(),
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
stop_id: uuidSchema.optional(),
});
// Checkout
export const checkoutSchema = z.object({
items: z.array(cartItemSchema),
customer_email: emailSchema,
customer_name: z.string().min(1).max(255),
customer_phone: phoneSchema.optional(),
customer_address: z.string().optional(),
notes: z.string().max(1000).optional(),
payment_method: z.enum(["stripe", "square"]).default("stripe"),
promo_code: z.string().optional(),
});
-155
View File
@@ -1,155 +0,0 @@
#!/usr/bin/env node
/**
* push-migrations.js
*
* Pushes migration files to the linked Supabase project.
* Uses the Supabase CLI (supabase db query --linked --file) if the project
* is linked (after `supabase login` + `supabase link --project-ref <ref>`),
* otherwise falls back to direct PostgreSQL connection via `pg`.
*
* Usage:
* node supabase/push-migrations.js # push all migration files
* node supabase/push-migrations.js 148 # push only migrations matching 148_*.sql
*
* Prerequisites for CLI mode (preferred, works even without direct 5432 access):
* supabase login
* supabase link --project-ref wnzkhezyhnfzhkhiflrp
*
* Prerequisites for direct PG mode:
* .env.local with NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY
* (pg and dotenv are in devDependencies)
*/
const { Client } = require("pg");
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
// Load .env.local for direct PG mode
try {
const dotenv = require("dotenv");
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
} catch {
// dotenv not available — CLI mode only
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
async function pushWithCli(files) {
console.log("Using Supabase CLI (supabase db query --linked --file)...\n");
for (const f of files) {
const filePath = `supabase/migrations/${f}`;
process.stdout.write(` Applying ${f}... `);
try {
execSync(
`supabase db query --linked --file "${filePath}"`,
{ stdio: "inherit", cwd: path.resolve(__dirname, "..") }
);
console.log("✓");
} catch (err) {
console.log("✗");
// execSync with inherit already printed stderr/stdout; surface a short message
const msg = (err && err.message) ? err.message.replace(/\n/g, " ").slice(0, 200) : "";
if (msg) console.error(" " + msg);
return false;
}
}
return true;
}
async function pushWithPg(sql, migrationFile) {
if (!supabaseUrl || !serviceRoleKey) {
console.error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
return false;
}
// Derive direct PG host from Supabase URL
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
const pgHost = `db.${projectRef}.supabase.co`;
const client = new Client({
host: pgHost,
port: 5432,
database: "postgres",
user: "postgres",
password: serviceRoleKey,
ssl: { rejectUnauthorized: false },
});
try {
await client.connect();
await client.query(sql);
console.log(` ✓ Applied: ${migrationFile}`);
return true;
} catch (err) {
const cleanMsg = err.message.replace(/\x1b\[[0-9;]*m/g, "");
console.error(` ERROR: ${cleanMsg.slice(0, 300)}`);
return false;
} finally {
await client.end();
}
}
async function main() {
const targetMigration = process.argv[2];
let files = fs.readdirSync(path.resolve(__dirname, "migrations"))
.filter((f) => f.endsWith(".sql"))
.sort();
if (targetMigration) {
files = files.filter((f) => f.startsWith(targetMigration + "_"));
if (files.length === 0) {
console.error(`No migration found matching: ${targetMigration}_*`);
process.exit(1);
}
}
console.log(`Found ${files.length} migration file(s) to push:`);
for (const f of files) console.log(` ${f}`);
console.log("");
let ok = false;
// Try CLI first if supabase is linked.
// Legacy link: .supabase/config.toml at project root
// Modern link (post `supabase link`): supabase/.temp/project-ref
let hasSupabaseCli = false;
try {
hasSupabaseCli = !!execSync("which supabase", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
} catch {
hasSupabaseCli = false;
}
const hasLegacyLink = fs.existsSync(path.resolve(__dirname, "../.supabase/config.toml"));
const hasModernLink = fs.existsSync(path.resolve(__dirname, ".temp/project-ref"));
const isLinked = hasLegacyLink || hasModernLink;
if (hasSupabaseCli && isLinked) {
console.log("Supabase CLI detected and project is linked.\n");
ok = await pushWithCli(files);
if (ok) {
console.log(`\nDone. ${files.length} migration(s) pushed via Supabase CLI.`);
return;
}
console.log("CLI mode failed, falling back to direct PG connection...\n");
}
// Direct PG fallback
for (const file of files) {
const sql = fs.readFileSync(path.resolve(__dirname, "migrations", file), "utf8");
process.stdout.write(`Pushing ${file}... `);
const fileOk = await pushWithPg(sql, file);
if (!fileOk) {
console.error(`\nMigration failed: ${file}`);
process.exit(1);
}
}
console.log(`\nDone. ${files.length} migration(s) pushed via direct PG.`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});