fix: react-doctor unused-file 123→27 (-96 dead files removed)
This commit is contained in:
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 "{stop.city}, {stop.state}"?</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 "{product.name}"?
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 "{product.name}"?
|
|
||||||
</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} />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
>
|
|
||||||
“
|
|
||||||
</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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
“{testimonial.quote}”
|
|
||||||
</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'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'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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 & 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 & 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 & 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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)} · {stop.time} · {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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" };
|
|
||||||
}
|
|
||||||
@@ -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 : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"];
|
|
||||||
@@ -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
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user