fix: react-doctor unused-file 27→0 (-27 more dead files, wholesale consolidate)

This commit is contained in:
Nora
2026-06-26 04:44:15 -06:00
parent 27b2ded94e
commit 3249ff0a55
27 changed files with 0 additions and 5352 deletions
-107
View File
@@ -1,107 +0,0 @@
"use server";
import { requireAuth } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
export type AIAuthConfig = {
provider: string;
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
};
export async function getAIPreferences(
brandId: string,
): Promise<{
api_key?: string;
organization_id?: string;
base_url?: string;
model?: string;
max_tokens?: number;
} | null> {
await requireAuth();
const { rows } = await pool.query<{
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
}>(
`SELECT api_key, organization_id, base_url, model, max_tokens
FROM brand_ai_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId],
);
return rows[0] ?? null;
}
export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig,
): Promise<{ success: boolean; error?: string }> {
await requireAuth();
try {
await pool.query(
`INSERT INTO brand_ai_settings (
brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (brand_id) DO UPDATE SET
provider = EXCLUDED.provider,
api_key = EXCLUDED.api_key,
organization_id = EXCLUDED.organization_id,
base_url = EXCLUDED.base_url,
model = EXCLUDED.model,
max_tokens = EXCLUDED.max_tokens,
updated_at = EXCLUDED.updated_at`,
[
brandId,
config.provider,
config.api_key || null,
config.organization_id || null,
config.base_url || null,
config.model || "gpt-4o-mini",
config.max_tokens || 4000,
],
);
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return { success: false, error: message };
}
}
export async function testAIConnection(
config: AIAuthConfig,
): Promise<{ ok: boolean; message: string }> {
await requireAuth();
if (!config.api_key?.trim()) {
return { ok: false, message: "API key is required" };
}
try {
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${config.api_key}`,
},
});
if (response.ok) {
return { ok: true, message: "Connection successful! Your API key is valid." };
}
if (response.status === 401) {
return { ok: false, message: "Invalid API key. Please check and try again." };
}
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
} catch {
return { ok: false, message: "Could not connect. Check your network and API key." };
}
}
-281
View File
@@ -1,281 +0,0 @@
"use server";
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type LocationInput = {
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;
};
export type Location = {
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;
};
export type LocationWithCount = Location & { stop_count: number };
// ── Create (single) ──────────────────────────────────────────────────────────
export async function createLocation(
brandId: string,
input: LocationInput
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized to manage locations" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
try {
const { rows } = await pool.query<{ id: string; slug: string }>(
`SELECT * FROM admin_create_location(
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
)`,
[
effectiveBrandId,
input.name,
input.address ?? null,
input.city ?? null,
input.state ?? null,
input.zip ?? null,
input.phone ?? null,
input.contact_name ?? null,
input.contact_email ?? null,
input.notes ?? null,
],
);
const data = rows[0];
if (!data) {
return { success: false, error: "Insert failed" };
}
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { success: true, id: data.id, slug: data.slug };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Insert failed",
};
}
}
// ── Create (batch) ───────────────────────────────────────────────────────────
export async function createLocationsBatch(
brandId: string,
locations: LocationInput[]
): Promise<{ success: boolean; created: number; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, created: 0, error: "Not authorized" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
let inserted: { id?: string }[] = [];
try {
const { rows } = await pool.query<{ id?: string }>(
"SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
[effectiveBrandId, JSON.stringify(locations)],
);
inserted = rows;
} catch (err) {
return {
success: false,
created: 0,
error: err instanceof Error ? err.message : "Insert failed",
};
}
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return {
success: true,
created: Array.isArray(inserted) ? inserted.length : locations.length,
};
}
// ── Update (partial) ─────────────────────────────────────────────────────────
export async function updateLocation(
locationId: string,
brandId: string,
updates: Partial<LocationInput>
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
let data: { success?: boolean; error?: string } = {};
try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
"SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
[locationId, brandId, JSON.stringify(updates)],
);
data = rows[0] ?? {};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Update failed",
};
}
if (!data.success) {
return { success: false, error: data.error ?? "Update failed" };
}
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
// ── Delete (soft) ────────────────────────────────────────────────────────────
export async function deleteLocation(
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
let data: { success?: boolean; error?: string } = {};
try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
"SELECT * FROM admin_delete_location($1, $2)",
[locationId, brandId],
);
data = rows[0] ?? {};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Delete failed",
};
}
if (!data.success) {
return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
export async function adminListLocations(
brandId: string
): Promise<LocationWithCount[]> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const activeBrandId = await getActiveBrandId(adminUser, brandId);
const effectiveBrandId = activeBrandId;
try {
const { rows } = await pool.query<LocationWithCount>(
"SELECT * FROM admin_list_locations($1)",
[effectiveBrandId],
);
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
// ── Read (public, by brand slug) ─────────────────────────────────────────────
export type PublicLocation = Pick<
Location,
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
> & { stop_count: number };
export async function getPublicLocationsForBrand(
brandSlug: string
): Promise<PublicLocation[]> {
await getSession(); if (!brandSlug) return [];
try {
const { rows } = await pool.query<PublicLocation>(
"SELECT * FROM get_locations_for_brand($1)",
[brandSlug],
);
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
// ── Attach a stop to a location ──────────────────────────────────────────────
export async function attachStopToLocation(
stopId: string,
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
let data: { success?: boolean; error?: string } = {};
try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
"SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
[stopId, locationId, brandId],
);
data = rows[0] ?? {};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Attach failed",
};
}
if (!data.success) {
return { success: false, error: data.error ?? "Attach failed" };
}
revalidateTag("stops", "default");
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
-157
View File
@@ -1,157 +0,0 @@
export interface HarvestLot {
id: string;
brand_id: string;
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
status: "active" | "in_transit" | "at_shed" | "packed" | "delivered";
notes: string | null;
source_stop_id: string | null;
destination_stop_id: string | null;
created_at: string;
updated_at: string;
bin_id: string | null;
container_id: string | null;
field_block: string | null;
pallets: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
}
export interface LotEvent {
id: string;
event_type: string;
event_time: string;
location: string | null;
notes: string | null;
created_by_name: string | null;
created_at: string;
bin_id: string | null;
}
export interface LotDetail {
lot_id: string;
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
status: string;
notes: string | null;
source_stop_id: string | null;
destination_stop_id: string | null;
created_at: string;
updated_at: string;
bin_id: string | null;
container_id: string | null;
field_block: string | null;
pallets: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
events: LotEvent[];
}
export interface RouteTraceStats {
active_count: number;
in_transit_count: number;
at_shed_count: number;
total_lots_today: number;
total_harvested_today: number;
total_lots: number;
}
export interface RecentLotEvent {
event_id: string;
event_type: string;
event_time: string;
location: string | null;
bin_id: string | null;
notes: string | null;
created_by_name: string | null;
lot_id: string;
lot_number: string;
crop_type: string;
status: string;
}
export interface LotOrder {
id: string;
customer_name: string;
order_date: string;
stop_name: string;
item_quantity: number | null;
item_notes: string | null;
fulfillment: string | null;
lot_quantity_used: number | null;
}
export interface TraceChain {
lot: HarvestLot;
events: LotEvent[];
orders: Array<{ id: string; customer_name: string; order_date: string; stop_name: string }>;
}
export interface HaulingLot {
lot_id: string;
lot_number: string;
crop_type: string;
harvest_date: string;
status: string;
field_location: string | null;
worker_name: string | null;
quantity_lbs: number | null;
yield_unit: string | null;
bin_id: string | null;
container_id: string | null;
field_block: string | null;
pallets: number | null;
destination_stop_id: string | null;
destination_stop_name: string | null;
destination_stop_time: string | null;
}
export interface FieldYieldSummary {
field_location: string;
field_block: string;
total_yield_estimate: number;
total_quantity_lbs: number;
active_lots: number;
yield_unit: string | null;
}
export interface InventoryByCrop {
crop_type: string;
status: string;
total_lbs: number;
total_estimate: number;
lot_count: number;
yield_unit: string | null;
}
export interface CreateLotData {
crop_type: string;
variety?: string;
harvest_date: string;
field_location?: string;
worker_name?: string;
packer_name?: string;
quantity_lbs?: number;
notes?: string;
destination_stop_id?: string;
bin_id?: string;
container_id?: string;
field_block?: string;
yield_estimate_lbs?: number;
yield_unit?: string;
pallets?: number;
}
-73
View File
@@ -1,73 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/auth";
import {
invalidateBrandFeatureCache,
type BrandFeatureKey,
} from "@/lib/feature-flags";
export type ToggleFeatureResult =
| { success: true }
| { success: false; error: string };
/**
* Toggle an add-on feature flag for a brand. The new schema stores feature
* flags as a JSONB blob in `brand_settings.feature_flags` — see
* `src/lib/feature-flags.ts` for the read path. The legacy RPC
* `set_brand_feature(p_brand_id, p_feature_key, p_enabled)` and the
* `brand_features` table it wrote to are gone.
*/
export async function toggleBrandFeature(
brandId: string,
featureKey: BrandFeatureKey,
enabled: boolean
): Promise<ToggleFeatureResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized for this brand" };
}
try {
await withBrand(brandId, async (db) => {
const existing = await db
.select({ featureFlags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.brandId, brandId))
.limit(1);
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
const next = { ...current, [featureKey]: enabled };
if (existing.length === 0) {
// No row yet — bootstrap with the brand name from tenants.
await db
.insert(brandSettings)
.values({ brandId: brandId, featureFlags: next });
} else {
await db
.update(brandSettings)
.set({ featureFlags: next, updatedAt: new Date() })
.where(eq(brandSettings.brandId, brandId));
}
});
invalidateBrandFeatureCache(brandId);
revalidatePath("/admin/settings/apps");
revalidatePath("/admin");
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to toggle feature",
};
}
}
-70
View File
@@ -1,70 +0,0 @@
"use server";
/**
* List the customers with pending pickups for a given stop.
*
* TODO(migration): the SaaS rebuild's `orders` table
* (db/schema/orders.ts) doesn't carry a `stop_id` column, so this
* helper queries the legacy `orders` table directly via `pool.query`
* for the customer-name / email / phone. When the new schema grows a
* `stop_id` reference, switch this read to a Drizzle query.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type StopCustomer = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
export type GetStopPendingCustomersResult =
| { success: true; customers: StopCustomer[] }
| { success: false; error: string };
export async function getStopPendingCustomers(
stopId: string
): Promise<GetStopPendingCustomersResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
// Resolve the stop's brand so we can scope-check.
const { rows: stopRows } = await pool.query<{ brand_id: string | null }>(
"SELECT brand_id::text AS brand_id FROM stops WHERE id = $1 LIMIT 1",
[stopId]
);
const brandId = stopRows[0]?.brand_id ?? null;
if (brandId) {
try { assertBrandAccess(adminUser, brandId); } catch {
return { success: false, error: "Brand access denied" };
}
}
try {
const { rows } = await pool.query<StopCustomer>(
`SELECT id::text AS id,
COALESCE(customer_name, '') AS customer_name,
customer_email,
customer_phone,
COALESCE(pickup_complete, false) AS pickup_complete
FROM orders
WHERE stop_id = $1
AND COALESCE(pickup_complete, false) = false
ORDER BY created_at DESC`,
[stopId]
);
return { success: true, customers: rows };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load customers",
};
}
}
-115
View File
@@ -1,115 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type StopDetail = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
active: boolean;
brand_id: string;
address: string | null;
zip: string | null;
cutoff_time: string | null;
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
};
export type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number } | null;
};
export type StopDetailsResult =
| {
success: true;
stop: StopDetail;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
/** admin_users.user_id of the caller, forwarded to RPCs that authorise via user_id lookup */
callerUid: string;
}
| { success: false; error: string };
/**
* Fetch a single stop with its brand, all candidate products, currently
* assigned products, and the list of brands (for the brand switcher in the
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
* component loaded, so the modal can be a drop-in replacement.
*/
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized" };
}
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
// maps to the new schema which doesn't have city/state/date/etc).
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
`SELECT s.*, b.name AS brand_name, b.slug AS brand_slug
FROM stops s
LEFT JOIN brands b ON b.id = s.brand_id
WHERE s.id = $1
LIMIT 1`,
[stopId],
);
const stopRow = stopRes.rows[0];
if (!stopRow) {
return { success: false, error: "Stop not found" };
}
const stop: StopDetail = {
...stopRow,
brands: stopRow.brand_name
? { name: stopRow.brand_name, slug: stopRow.brand_slug }
: null,
};
// Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
// 2 + 3 + 4. Candidate products, assigned products, and brand switcher
// list — all independent reads, fetched in parallel.
const [
{ rows: allProducts },
{ rows: productStops },
{ rows: brands },
] = await Promise.all([
pool.query<{ id: string; name: string; type: string; price: number }>(
`SELECT id, name, type, price
FROM products
WHERE brand_id = $1 AND active = true`,
[stop.brand_id],
),
pool.query<AssignedProduct>(
`SELECT ps.id, ps.product_id,
json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products
FROM product_stops ps
LEFT JOIN products p ON p.id = ps.product_id
WHERE ps.stop_id = $1`,
[stopId],
),
pool.query<{ id: string; name: string; slug: string }>(
`SELECT id, name, slug FROM brands ORDER BY name`,
),
]);
return {
success: true,
stop,
allProducts: allProducts ?? [],
assignedProducts: (productStops ?? []) as AssignedProduct[],
brands: brands ?? [],
callerUid: adminUser.user_id,
};
}
-258
View File
@@ -1,258 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import Stripe from "stripe";
import { getSession } from "@/lib/auth";
// Stripe API version type
type StripeApiVersion = "2026-06-24.dahlia";
/**
* Get Stripe Connect status for a brand.
* Checks if brand has a stripe_user_id (connected account).
*/
export async function getStripeConnectStatus(brandId: string): Promise<{
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
error?: string;
}> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" };
// Get brand's payment settings via SECURITY DEFINER RPC
const { rows } = await pool.query<{ stripe_user_id: string | null }>(
"SELECT * FROM get_brand_payment_settings($1)",
[brandId]
);
const stripeUserId = rows[0]?.stripe_user_id ?? null;
if (!stripeUserId) {
return { is_connected: false };
}
// Verify the account exists and get status
try {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { is_connected: true, account_id: stripeUserId };
}
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const account = await stripe.accounts.retrieve(stripeUserId);
return {
is_connected: true,
account_id: stripeUserId,
charges_enabled: account.charges_enabled,
payouts_enabled: account.payouts_enabled,
details_submitted: account.details_submitted,
};
} catch {
// If we can't verify, assume connected but with stale data
return {
is_connected: true,
account_id: stripeUserId,
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
};
}
}
/**
* Create a new Stripe Connect Express account for a brand
* and return an onboarding link.
*/
export async function createStripeConnectLink(brandId: string): Promise<{
success: boolean;
url?: string;
account_id?: string;
error?: string;
}> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe is not configured on this platform" };
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// Create Express account
const account = await stripe.accounts.create({
type: "express",
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
metadata: {
brand_id: brandId,
},
});
// Create account link for onboarding
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${origin}/admin/advanced?stripe_refresh=true`,
return_url: `${origin}/admin/advanced?stripe_connected=true`,
type: "account_onboarding",
});
return {
success: true,
url: accountLink.url,
account_id: account.id,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create Stripe account";
return { success: false, error: message };
}
}
/**
* Create a new account link for an existing Stripe Connect account
* (for refreshing expired links or re-onboarding)
*/
export async function refreshStripeConnectLink(brandId: string): Promise<{
success: boolean;
url?: string;
error?: string;
}> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
// Get existing account ID
const status = await getStripeConnectStatus(brandId);
if (!status.is_connected || !status.account_id) {
// No existing account, create new one
return createStripeConnectLink(brandId);
}
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe is not configured on this platform" };
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const accountLink = await stripe.accountLinks.create({
account: status.account_id,
refresh_url: `${origin}/admin/advanced?stripe_refresh=true`,
return_url: `${origin}/admin/advanced?stripe_connected=true`,
type: "account_onboarding",
});
return {
success: true,
url: accountLink.url,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to refresh onboarding link";
return { success: false, error: message };
}
}
/**
* Save Stripe Connect account ID to brand settings
*/
export async function saveStripeConnectAccount(brandId: string, accountId: string): Promise<{
success: boolean;
error?: string;
}> {
await getSession(); try {
// Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
"SELECT set_stripe_connect_account($1, $2)",
[brandId, accountId]
);
return { success: true };
} catch (e: unknown) {
const error = e instanceof Error ? e.message : String(e);
return { success: false, error: `Failed to save: ${error}` };
}
}
/**
* Disconnect Stripe Connect account from a brand
*/
export async function disconnectStripeConnect(brandId: string): Promise<{
success: boolean;
error?: string;
}> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
try {
// SECURITY DEFINER RPC disconnects the Stripe Connect account
await pool.query(
"SELECT disconnect_stripe_connect($1)",
[brandId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to disconnect Stripe account" };
}
}
/**
* Create a Stripe Express dashboard login link
*/
export async function createStripeDashboardLink(brandId: string): Promise<{
success: boolean;
url?: string;
error?: string;
}> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
const status = await getStripeConnectStatus(brandId);
if (!status.is_connected || !status.account_id) {
return { success: false, error: "No Stripe account connected" };
}
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe is not configured" };
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const loginLink = await stripe.accounts.createLoginLink(status.account_id);
return {
success: true,
url: loginLink.url,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create dashboard link";
return { success: false, error: message };
}
}
-99
View File
@@ -1,99 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleCustomer } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleCustomer(params: {
brandId: string;
userId?: string;
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
accountStatus?: string;
creditLimit?: number;
depositsEnabled?: boolean;
depositThreshold?: number;
depositPercentage?: number;
orderEmail?: string;
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
[
params.brandId,
params.userId ?? null,
params.companyName ?? null,
params.contactName ?? null,
params.email ?? null,
params.phone ?? null,
params.accountStatus ?? "active",
params.creditLimit ?? 0,
params.depositsEnabled ?? false,
params.depositThreshold ?? null,
params.depositPercentage ?? null,
params.orderEmail ?? null,
params.invoiceEmail ?? null,
params.adminNotes ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save customer" };
}
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_customer($1)",
[customerId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
}
}
-89
View File
@@ -1,89 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enqueueWholesaleWebhook } from "./webhooks";
import { getSession } from "@/lib/auth";
export async function recordWholesaleDeposit(
orderId: string,
amount: number,
method: string = "cash",
reference?: string,
brandId?: string
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
let data: { success?: boolean; error?: string } | null = null;
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
);
data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to record deposit" };
}
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
}
// Fire webhook — fire-and-forget
enqueueWholesaleWebhook("deposit_recorded", orderId, { order_id: orderId, amount }, activeBrandId ?? undefined).catch(() => {});
return { success: true };
}
export async function bulkFulfillWholesaleOrders(
orderIds: string[]
): Promise<{ success: boolean; count?: number; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
}
}
export async function bulkRecordWholesaleDeposit(
orderIds: string[],
amount: number,
method: string = "cash",
reference?: string
): Promise<{ success: boolean; count?: number; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
}
}
-76
View File
@@ -1,76 +0,0 @@
/**
* Barrel re-export for the wholesale action set.
*
* Source modules live next to this file:
* - orders.ts — order CRUD + dashboard stats
* - customers.ts — wholesale customer CRUD
* - products.ts — wholesale product CRUD
* - settings.ts — wholesale_settings + public read
* - deposits.ts — deposit recording + bulk actions
* - notifications.ts — email/SMS notification queue
* - webhooks.ts — outbound webhook settings + dispatch
* - scope.ts — internal brand-scoping helpers (NOT re-exported here)
* - types.ts — shared type definitions
*
* Existing imports of `@/actions/wholesale` keep working. New code should
* import directly from the focused modules to keep the dependency graph
* tight.
*/
export type {
WholesaleOrder,
WholesaleCustomer,
WholesaleProduct,
NotificationRecipient,
WholesaleSettings,
WholesaleDashboardStats,
WholesaleNotification,
WebhookSettings,
} from "./types";
export {
getWholesaleOrders,
getWholesalePickupOrders,
getWholesaleDashboardStats,
markWholesaleOrderFulfilled,
updateWholesaleOrderStatus,
deleteWholesaleOrder,
} from "./orders";
export {
getWholesaleCustomers,
saveWholesaleCustomer,
deleteWholesaleCustomer,
} from "./customers";
export {
getWholesaleProducts,
saveWholesaleProduct,
deleteWholesaleProduct,
} from "./products";
export {
getWholesaleSettings,
getWholesaleSettingsPublic,
saveWholesaleSettings,
} from "./settings";
export {
recordWholesaleDeposit,
bulkFulfillWholesaleOrders,
bulkRecordWholesaleDeposit,
} from "./deposits";
export {
getWholesaleNotificationStats,
getWholesalePendingNotifications,
markWholesaleNotificationSent,
enqueueWholesaleNotification,
} from "./notifications";
export {
getWebhookSettings,
saveWebhookSettings,
enqueueWholesaleWebhook,
getRecentWebhookActivity,
} from "./webhooks";
-94
View File
@@ -1,94 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WholesaleNotification } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
await getSession(); try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
);
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
} catch {
return { pending: 0, sent: 0, failed: 0, total: 0 };
}
}
export async function getWholesalePendingNotifications(
brandId: string,
limit = 50
): Promise<WholesaleNotification[]> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
try {
const { rows } = await pool.query<WholesaleNotification>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
export async function markWholesaleNotificationSent(
notificationId: string,
error?: string
): Promise<{ success: boolean }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
try {
await pool.query(
"SELECT mark_wholesale_notification_sent($1, $2)",
[notificationId, error ?? null]
);
return { success: true };
} catch {
return { success: false };
}
}
export async function enqueueWholesaleNotification(params: {
brandId: string;
customerId: string;
orderId: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
emailTo: string;
emailCc?: string;
subject: string;
bodyHtml?: string;
bodyText?: string;
}): Promise<{ success: boolean; error?: string }> {
await getSession(); try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
params.brandId,
params.customerId,
params.orderId,
params.type,
params.emailTo,
params.emailCc ?? null,
params.subject,
params.bodyHtml ?? null,
params.bodyText ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to enqueue notification" };
}
}
-135
View File
@@ -1,135 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enforceBrandScope, resolveBrandId } from "./scope";
import { enqueueWholesaleWebhook } from "./webhooks";
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_pickup_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
await getSession(); const orders = await getWholesaleOrders(brandId);
const today = new Date().toISOString().split("T")[0];
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
return {
open_orders: open.length,
pickup_today: pickupToday.length,
past_due: pastDue.length,
total_unpaid: totalUnpaid,
awaiting_deposit: awaitingDep.length,
fulfilled_today: fulfilledToday.length,
};
}
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query(
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
[orderId, adminUser.user_id]
);
if (!rows || rows.length === 0) {
return { success: false, error: "Failed to mark fulfilled" };
}
} catch {
return { success: false, error: "Failed to mark fulfilled" };
}
enqueueWholesaleWebhook("order_fulfilled", orderId, { order_id: orderId }, resolved ?? undefined).catch(() => {});
return { success: true };
}
export async function updateWholesaleOrderStatus(
orderId: string,
status: "pending" | "confirmed" | "cancelled",
brandId?: string
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM update_wholesale_order_status($1, $2)",
[orderId, status]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update order status" };
}
}
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM delete_wholesale_order($1)",
[orderId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to delete order" };
}
}
-105
View File
@@ -1,105 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleProduct } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleProduct>(
"SELECT * FROM get_wholesale_products($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleProduct(params: {
brandId: string;
id?: string;
name: string;
description?: string;
unitType?: string;
availability?: string;
qtyAvailable?: number;
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
hpSku?: string;
hpItemId?: string;
internalNotes?: string;
handlingInstructions?: string;
storageWarning?: string;
productLabel?: string;
packStyle?: string;
containerType?: string;
defaultPickupLocation?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
[
params.brandId,
params.id ?? null,
params.name,
params.description ?? null,
params.unitType ?? "each",
params.availability ?? "unavailable",
params.qtyAvailable ?? 0,
JSON.stringify(params.priceTiers ?? []),
params.hpSku ?? null,
params.hpItemId ?? null,
params.internalNotes ?? null,
params.handlingInstructions ?? null,
params.storageWarning ?? null,
params.productLabel ?? null,
params.packStyle ?? null,
params.containerType ?? null,
params.defaultPickupLocation ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save product" };
}
}
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_product($1)",
[productId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
}
}
-71
View File
@@ -1,71 +0,0 @@
/**
* Brand scoping helpers shared across the wholesale actions.
*
* No "use server" so this can also be imported from non-action contexts
* (e.g. middleware, tests).
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getSession } from "@/lib/auth";
/**
* Resolves the effective brand_id for an action, enforcing brand scoping.
*
* platform_admin → null (means "all brands" — passes to RPC unchanged)
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
* store_employee → their own brand_id
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated → null (actions should already bail out earlier)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
export async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<string | null> {
await getSession(); if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
// platform_admin can operate on all brands — pass null (= all brands) to RPC
return null;
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return activeBrandId;
}
/**
* Like resolveBrandId but returns null for platform_admin AND throws an error
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
export async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
await getSession(); if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: activeBrandId };
}
-91
View File
@@ -1,91 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import type { NotificationRecipient, WholesaleSettings } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
try {
const { rows } = await pool.query<WholesaleSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
await getSession(); try {
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWholesaleSettings(params: {
brandId: string;
requireApproval?: boolean;
minOrderAmount?: number;
onlinePaymentEnabled?: boolean;
wholesaleEnabled?: boolean;
squareSyncEnabled?: boolean;
pickupLocation?: string;
fobLocation?: string;
fromEmail?: string;
invoiceBusinessName?: string;
invoiceBusinessAddress?: string;
invoiceBusinessPhone?: string;
invoiceBusinessEmail?: string;
invoiceBusinessWebsite?: string;
notificationEmail?: string;
notificationRecipients?: NotificationRecipient[];
}): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
[
params.brandId,
params.requireApproval ?? null,
params.minOrderAmount ?? null,
params.onlinePaymentEnabled ?? null,
params.wholesaleEnabled ?? null,
params.pickupLocation ?? null,
params.fobLocation ?? null,
params.fromEmail ?? null,
params.invoiceBusinessName ?? null,
params.invoiceBusinessAddress ?? null,
params.invoiceBusinessPhone ?? null,
params.invoiceBusinessEmail ?? null,
params.invoiceBusinessWebsite ?? null,
params.notificationEmail ?? null,
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
params.squareSyncEnabled ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save settings" };
}
}
-153
View File
@@ -1,153 +0,0 @@
/**
* Shared types for wholesale actions.
*
* Pure type definitions — no runtime imports, no "use server" so it can
* be imported into both server and client code.
*/
export type WholesaleOrder = {
id: string;
customer_id: string;
status: string;
fulfillment_status: string;
payment_status: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_required: number;
deposit_paid: number;
balance_due: number;
created_at: string;
updated_at: string;
invoice_number: string | null;
assigned_employee_id: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
items: Array<{
id: string;
product_name: string;
quantity: number;
unit_price: number;
line_total: number;
}>;
fulfilled_at: string | null;
};
export type WholesaleCustomer = {
id: string;
user_id: string | null;
company_name: string;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
credit_limit: number;
deposits_enabled: boolean;
deposit_threshold: number | null;
deposit_percentage: number | null;
order_email: string | null;
invoice_email: string | null;
admin_notes: string | null;
role: string;
created_at: string;
deleted_at: string | null;
};
export type WholesaleProduct = {
id: string;
name: string;
description: string | null;
unit_type: string;
unit_type_custom: string | null;
availability: string;
qty_available: number;
season_start: string | null;
season_end: string | null;
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
hp_sku: string | null;
hp_item_id: string | null;
handling_instructions: string | null;
storage_warning: string | null;
loading_notes: string | null;
product_label: string | null;
pack_style: string | null;
container_type: string | null;
container_size_code: string | null;
units_per_container: number | null;
default_pickup_location: string | null;
created_at: string;
deleted_at: string | null;
};
export type NotificationRecipient = {
email: string;
name?: string;
active: boolean;
notification_types?: (
| "order_confirmation"
| "deposit_received"
| "order_fulfilled"
| "price_sheet"
| "unclaimed_pickup"
)[];
};
export type WholesaleSettings = {
id: string;
brand_id: string;
portal_page_id: string | null;
price_sheet_page_id: string | null;
require_approval: boolean;
min_order_amount: number | null;
online_payment_enabled: boolean;
wholesale_enabled: boolean;
square_sync_enabled: boolean;
pickup_location: string;
fob_location: string;
from_email: string;
invoice_business_name: string;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
notification_email: string | null;
notification_recipients: NotificationRecipient[];
last_invoice_number: number;
};
export type WholesaleDashboardStats = {
open_orders: number;
pickup_today: number;
past_due: number;
total_unpaid: number;
awaiting_deposit: number;
fulfilled_today: number;
};
export type WholesaleNotification = {
id: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
email_to: string;
email_cc: string | null;
subject: string;
body_html: string | null;
body_text: string | null;
brand_id: string;
customer_id: string;
order_id: string | null;
status: string;
invoice_business_name: string | null;
invoice_business_email: string | null;
created_at: string;
};
export type WebhookSettings = {
id: string;
brand_id: string;
url: string;
secret: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
-96
View File
@@ -1,96 +0,0 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WebhookSettings } from "./types";
import { getSession } from "@/lib/auth";
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
await getSession(); try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWebhookSettings(params: {
brandId: string;
url?: string;
secret?: string;
enabled?: boolean;
}): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
[
JSON.stringify({
brand_id: params.brandId,
url: params.url ?? "",
secret: params.secret ?? "",
enabled: params.enabled ?? false,
}),
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save webhook settings" };
}
}
export async function enqueueWholesaleWebhook(
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
orderId: string | null = null,
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{ success: boolean; logId?: string }> {
await getSession(); try {
const { rows } = await pool.query<{ id: string }>(
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, logId: data?.id };
} catch {
return { success: false };
}
}
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>> {
await getSession(); try {
const { rows } = await pool.query<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>(
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
-288
View File
@@ -1,288 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { createLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
}
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
}
type Props = {
isOpen: boolean;
onClose: () => void;
brandId: string;
onSuccess?: (locationId: string) => void;
};
export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [stateVal, setStateVal] = useState("");
const [zip, setZip] = useState("");
const [phone, setPhone] = useState("");
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
const reset = useCallback(() => {
setName("");
setAddress("");
setCity("");
setStateVal("");
setZip("");
setPhone("");
setContactName("");
setContactEmail("");
setNotes("");
setError(null);
}, []);
const handleClose = useCallback(() => {
if (loading) return;
reset();
onClose();
}, [loading, reset, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await createLocation(brandId, {
name: name.trim(),
address: address.trim() || null,
city: city.trim() || null,
state: stateVal.trim().toUpperCase() || null,
zip: zip.trim() || null,
phone: phone.trim() || null,
contact_name: contactName.trim() || null,
contact_email: contactEmail.trim() || null,
notes: notes.trim() || null,
active: true,
});
if (result.success) {
onSuccess?.(result.id);
reset();
onClose();
} else {
setError(result.error ?? "Failed to create location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
);
if (!isOpen) return null;
return (
<GlassModal
title="Add Venue"
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label htmlFor="fld-1-venue-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input id="fld-1-venue-name" aria-label="Tractor Supply"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tractor Supply"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-2-street-address" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input id="fld-2-street-address" aria-label="13778 E I 25 Frontage Rd"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="13778 E I-25 Frontage Rd"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label htmlFor="fld-3-city" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input id="fld-3-city" aria-label="Wellington"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Wellington"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label htmlFor="fld-4-state" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input id="fld-4-state" aria-label="CO"
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label htmlFor="fld-5-zip" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input id="fld-5-zip" aria-label="80549"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80549"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label htmlFor="fld-6-phone" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input id="fld-6-phone" aria-label="(970) 555 1234"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(970) 555-1234"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-7-contact-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input id="fld-7-contact-name" aria-label="Store Manager"
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Store manager"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-8-contact-email" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input id="fld-8-contact-email" aria-label="Manager@example.com"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-9-notes" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea id="fld-9-notes" aria-label="Park On West Side. Use Side Entrance After 9am."
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Park on west side. Use side entrance after 9am."
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating
</span>
) : (
"Create Venue"
)}
</button>
</div>
</form>
</GlassModal>
);
}
-419
View File
@@ -1,419 +0,0 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { createStop } from "@/actions/stops/create-stop";
import GlassModal from "@/components/admin/GlassModal";
type Props = {
isOpen: boolean;
onClose: () => void;
brandId: string;
duplicateFrom?: {
city: string;
state: string;
location: string;
date: string;
time: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
} | null;
onSuccess?: (stopId: string) => void;
};
/* Pin icon for the modal header */
const PinIcon = () => (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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>
);
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [city, setCity] = useState("");
const [stateField, setStateField] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [address, setAddress] = useState("");
const [zip, setZip] = useState("");
const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft");
const cityRef = useRef<HTMLInputElement>(null);
// Reset form when modal opens (or duplicateFrom changes) — derived
// during render per React docs: "Adjusting some state when a prop
// changes". See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevOpenKey, setPrevOpenKey] = useState<string | null>(null);
const openKey = isOpen ? `open:${duplicateFrom ? "dup" : "new"}` : "closed";
if (openKey !== prevOpenKey) {
setPrevOpenKey(openKey);
setCity(duplicateFrom?.city ?? "");
setStateField(duplicateFrom?.state ?? "");
setLocation(duplicateFrom?.location ?? "");
setDate(duplicateFrom?.date ?? "");
setTime(duplicateFrom?.time ?? "");
setAddress(duplicateFrom?.address ?? "");
setZip(duplicateFrom?.zip ?? "");
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
setStatus("draft");
setError(null);
}
// Focus the first field once the modal has actually opened. This is
// a DOM side-effect, so it must run after the commit (i.e. in an
// effect) — accessing `cityRef.current` during render is illegal.
// The `prevOpenKey === "open:..."` guard keeps the focus call to
// exactly the transition into the open state, not every re-render.
useEffect(() => {
if (prevOpenKey?.startsWith("open:")) {
const id = requestAnimationFrame(() => cityRef.current?.focus());
return () => cancelAnimationFrame(id);
}
return undefined;
}, [prevOpenKey]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
return;
}
setLoading(true);
try {
const result = await createStop(brandId, {
city: city.trim(),
state: stateField.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
if (result.success) {
onSuccess?.(result.id);
onClose();
} else {
setError(result.error ?? "Failed to create stop");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[
brandId,
city,
stateField,
location,
date,
time,
address,
zip,
cutoffTime,
status,
onSuccess,
onClose,
]
);
const isDuplicate = Boolean(duplicateFrom);
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
const eyebrow = isDuplicate && duplicateFrom
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
: "New stop on the route";
const submitLabel = loading
? isDuplicate ? "Duplicating…" : "Creating…"
: isDuplicate
? "Duplicate Stop"
: status === "active"
? "Create & Publish"
: "Save as Draft";
if (!isOpen) return null;
return (
<GlassModal
title={title}
eyebrow={eyebrow}
onClose={onClose}
maxWidth="max-w-xl"
compact
>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
{error && (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div>
)}
{/* Row 1 — Where: City + State */}
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Denver"
ref={cityRef}
id="stop-city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="ha-field-input"
required
autoComplete="address-level2"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="CO"
id="stop-state"
type="text"
value={stateField}
onChange={(e) => setStateField(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="ha-field-input ha-field-input-mono text-center"
style={{ textTransform: "uppercase" }}
required
autoComplete="address-level1"
/>
</div>
</div>
{/* Row 2 — Where at: Location (full) */}
<div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Whole Foods Market — Highlands"
id="stop-location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market — Highlands"
className="ha-field-input"
required
/>
</div>
{/* Row 3 — When: Date + Time (with small clock icon) */}
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Stop Date"
id="stop-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="ha-field-input ha-field-input-mono"
required
/>
</div>
<div className="ha-field">
<label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<input aria-label="Stop Time"
id="stop-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<input aria-label="123 Main St"
id="stop-address"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="ha-field-input"
autoComplete="street-address"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input aria-label="80202"
id="stop-zip"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
maxLength={10}
className="ha-field-input ha-field-input-mono"
autoComplete="postal-code"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input aria-label="Stop Cutoff"
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
<div className="ha-field pt-0.5">
<div className="flex items-center justify-between">
<p className="ha-field-label">
<span>Visibility</span>
</p>
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
Draft is hidden from customers
</span>
</div>
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<button
type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => setStatus("draft")}
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
>
<span className="ha-segment-dot" />
<span>Save as Draft</span>
</button>
<button
type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => setStatus("active")}
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
>
<span className="ha-segment-dot" />
<span>Publish Now</span>
</button>
</div>
</div>
{/* Footer */}
<div className="ha-modal-footer">
<span className="ha-modal-footer-hint">
<kbd>Esc</kbd>
to close
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="ha-btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="ha-btn-primary"
>
{loading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<span>{submitLabel}</span>
</>
) : (
<>
{status === "active" ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
<span>{submitLabel}</span>
</>
)}
</button>
</div>
</div>
</form>
</GlassModal>
);
}
// Hook for easy integration
export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicateFrom"]) {
const [isOpen, setIsOpen] = useState(false);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const Modal = useCallback(() => (
<AddStopModal
isOpen={isOpen}
onClose={close}
brandId={brandId}
duplicateFrom={duplicateFrom}
/>
), [isOpen, close, brandId, duplicateFrom]);
return { open, close, Modal };
}
-574
View File
@@ -1,574 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
// Types
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
interface AISettings {
provider: AIProvider;
apiKey: string;
orgId: string;
model: string;
customEndpoint: string;
}
interface TestResult {
ok: boolean;
message: string;
}
// Provider configuration
const PROVIDERS = [
{
id: "openai" as AIProvider,
name: "OpenAI",
color: "bg-emerald-500",
placeholder: "sk-...",
website: "openai.com",
description: "GPT-4o, GPT-4 Turbo. Industry standard.",
models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
hasOrgId: true,
},
{
id: "anthropic" as AIProvider,
name: "Anthropic",
color: "bg-amber-500",
placeholder: "sk-ant-...",
website: "anthropic.com",
description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.",
models: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
hasOrgId: false,
},
{
id: "google" as AIProvider,
name: "Google",
color: "bg-blue-500",
placeholder: "AIza...",
website: "ai.google",
description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.",
models: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
hasOrgId: false,
},
{
id: "xai" as AIProvider,
name: "xAI",
color: "bg-orange-500",
placeholder: "xai-...",
website: "x.ai",
description: "Grok-2, Grok-1.5. Real-time data, humor.",
models: ["grok-2", "grok-2-mini", "grok-1.5"],
hasOrgId: false,
},
{
id: "custom" as AIProvider,
name: "Custom",
color: "bg-stone-500",
placeholder: "sk-...",
website: "",
description: "Any OpenAI-compatible API endpoint.",
models: ["gpt-4o-mini"],
hasOrgId: false,
},
] as const;
// Estimated costs per 1M tokens (for display only)
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
"gpt-4o": { input: 2.50, output: 10.00 },
"gpt-4o-mini": { input: 0.15, output: 0.60 },
"gpt-4-turbo": { input: 10.00, output: 30.00 },
"gpt-3.5-turbo": { input: 0.50, output: 1.50 },
"claude-3-5-sonnet-20241002": { input: 3.00, output: 15.00 },
"claude-3-5-sonnet-20250611": { input: 3.00, output: 15.00 },
"claude-3-opus-20240229": { input: 15.00, output: 75.00 },
"claude-3-haiku-20240307": { input: 0.80, output: 4.00 },
"gemini-2.0-flash": { input: 0.10, output: 0.40 },
"gemini-1.5-pro": { input: 1.25, output: 5.00 },
"gemini-1.5-flash": { input: 0.075, output: 0.30 },
};
// Icons
const SparkleIcon = ({ className }: { 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.09z"/>
</svg>
);
const RobotIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2"/>
<circle cx="12" cy="5" r="2"/>
<path d="M12 7v4"/>
<line x1="8" y1="16" x2="8" y2="16"/>
<line x1="16" y1="16" x2="16" y2="16"/>
</svg>
);
const BrainIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-2.5 2.5M4.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M14.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M9.5 16a2.5 2.5 0 015 0"/>
<path d="M12 4.5v15"/>
</svg>
);
const CircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="4"/>
</svg>
);
const TornadoIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H3M21 4v16M3 8l3 3 3-3 3 3 3-3 3 3M3 16l3 3 3-3 3 3 3-3 3 3"/>
</svg>
);
const WrenchIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const BotIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8V4H8"/>
<rect x="4" y="8" width="16" height="12" rx="2"/>
<path d="M2 14h2M20 14h2M15 13v2M9 13v2"/>
</svg>
);
const PROVIDER_ICONS: Record<AIProvider, React.ReactNode> = {
openai: <RobotIcon className="h-5 w-5" />,
anthropic: <BrainIcon className="h-5 w-5" />,
google: <CircleIcon className="h-5 w-5" />,
xai: <TornadoIcon className="h-5 w-5" />,
custom: <WrenchIcon className="h-5 w-5" />,
};
type Props = {
brandId: string;
};
export default function AdvancedAIPanel({ brandId }: Props) {
// Settings state
const [settings, setSettings] = useState<AISettings>({
provider: "openai",
apiKey: "",
orgId: "",
model: "gpt-4o-mini",
customEndpoint: "",
});
// UI state
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [hasChanges, setHasChanges] = useState(false);
// Load existing settings via the AI-provider server action. Server actions
// are normal async functions on the client (no `fetch` to flag) and the
// action's auth gate ensures only the brand admin can read these settings.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const data = await getAIProviderSettings(brandId);
if (cancelled) return;
setSettings({
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.apiKey ?? "",
orgId: data.orgId ?? "",
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.customEndpoint ?? "",
});
} catch (err) {
if (!cancelled) console.error("Failed to load AI settings:", err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [brandId]);
// Track changes
const handleChange = useCallback((field: keyof AISettings, value: string) => {
setSettings((prev) => ({ ...prev, [field]: value }));
setHasChanges(true);
setResult(null);
}, []);
// Select provider
const handleSelectProvider = useCallback((provider: AIProvider) => {
const providerConfig = PROVIDERS.find((p) => p.id === provider);
const defaultModel = providerConfig?.models[0] ?? "gpt-4o-mini";
setSettings((prev) => ({
...prev,
provider,
model: defaultModel,
}));
setHasChanges(true);
setResult(null);
}, []);
// Save settings
const handleSave = useCallback(async () => {
setSaving(true);
setResult(null);
try {
const res = await fetch("/api/integrations/ai-provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brandId,
...settings,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed to save");
setResult({ ok: true, message: "AI provider settings saved successfully" });
setHasChanges(false);
} catch (err) {
setResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
} finally {
setSaving(false);
}
}, [brandId, settings]);
// Test connection
const handleTest = useCallback(async () => {
if (!settings.apiKey?.trim()) {
setResult({ ok: false, message: "API key is required to test connection" });
return;
}
setTesting(true);
setResult(null);
try {
const res = await fetch("/api/integrations/ai-provider/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Connection failed");
setResult({ ok: true, message: data.message ?? "Connection successful" });
} catch (err) {
setResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
} finally {
setTesting(false);
}
}, [brandId, settings.apiKey]);
// Get current provider config
const currentProvider = PROVIDERS.find((p) => p.id === settings.provider) ?? PROVIDERS[0];
const modelCosts = MODEL_COSTS[settings.model];
// Loading skeleton
if (loading) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 animate-pulse">
<div className="h-4 bg-amber-100 rounded w-1/3 mb-2" />
<div className="h-3 bg-amber-100 rounded w-2/3" />
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6 animate-pulse">
<div className="h-6 bg-stone-200 rounded w-1/4 mb-4" />
<div className="grid grid-cols-5 gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-20 bg-stone-100 rounded-xl" />
))}
</div>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Security Warning Banner */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Never share keys</strong> Don&apos;t paste in Slack, email, or screenshots.</li>
<li> <strong>Usage costs add up fast</strong> AI APIs charge per token. A busy month can hit $50-$200+.</li>
<li> <strong>Set budgets now</strong> Use provider dashboard limits. Start with $10-$25/mo cap.</li>
</ul>
</div>
</div>
</div>
{/* Provider Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500">
<SparkleIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Choose your AI model provider for all AI tools</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
{PROVIDERS.map((provider) => (
<button type="button"
key={provider.id}
onClick={() => handleSelectProvider(provider.id)}
className={`rounded-xl p-3 text-center transition-all border-2 ${
settings.provider === provider.id
? "border-violet-500 bg-violet-50"
: "border-[var(--admin-border)] hover:border-violet-200 hover:bg-stone-50"
}`}
>
<div className={`flex h-10 w-10 mx-auto items-center justify-center rounded-xl mb-2 ${provider.color}`}>
<span className="text-white">{PROVIDER_ICONS[provider.id]}</span>
</div>
<p className="text-xs font-semibold text-[var(--admin-text-primary)]">{provider.name}</p>
{provider.website && (
<p className="text-[10px] text-[var(--admin-text-muted)] mt-0.5">{provider.website}</p>
)}
</button>
))}
</div>
</div>
</div>
{/* Credentials Card */}
{settings.provider !== "custom" ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Credentials</h3>
<span className="text-xs text-[var(--admin-text-muted)]">{currentProvider.website}</span>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* API Key */}
<div>
<label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<input id="fld-1-api-key" aria-label="Input"
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
placeholder={currentProvider.placeholder}
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
<p className="mt-1 text-[10px] text-stone-400">
Get your API key from {currentProvider.website}
</p>
</div>
{/* Organization ID (OpenAI only) */}
{currentProvider.hasOrgId && (
<div>
<label htmlFor="fld-2-organization-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Organization ID <span className="text-stone-400">(optional)</span>
</label>
<input id="fld-2-organization-id" aria-label="Org ..."
type="text"
value={settings.orgId}
onChange={(e) => handleChange("orgId", e.target.value)}
placeholder="org-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
)}
</div>
</div>
) : (
/* Custom Endpoint Card */
<div className="rounded-xl border border-violet-200 bg-violet-50 overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-violet-200">
<div className="flex items-center gap-3">
<WrenchIcon className="h-5 w-5 text-violet-600" />
<div>
<p className="text-sm font-semibold text-violet-800">Custom API Endpoint</p>
<p className="text-xs text-violet-600 mt-0.5">Connect any OpenAI-compatible API</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
<div>
<label htmlFor="fld-3-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<input id="fld-3-api-key" aria-label="Sk ..."
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<button type="button" onClick={() => setShowApiKey(!showApiKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label htmlFor="fld-4-base-url" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Base URL <span className="text-red-500">*</span>
</label>
<input id="fld-4-base-url" aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
type="text"
value={settings.customEndpoint}
onChange={(e) => handleChange("customEndpoint", e.target.value)}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<p className="mt-1 text-[10px] text-stone-500">
Examples: OpenAI proxy, Ollama (localhost:11434), LM Studio
</p>
</div>
</div>
</div>
)}
{/* Model Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Model</h3>
{modelCosts && (
<div className="text-right">
<span className="text-[10px] text-[var(--admin-text-muted)]">Est. cost per 1M tokens:</span>
<div className="flex gap-2 text-[10px]">
<span className="text-stone-500">In: ${modelCosts.input}</span>
<span className="text-stone-500">Out: ${modelCosts.output}</span>
</div>
</div>
)}
</div>
</div>
<div className="p-4 sm:p-6">
<div className="flex flex-wrap gap-2">
{currentProvider.models.map((model) => (
<button type="button"
key={model}
onClick={() => handleChange("model", model)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
settings.model === model
? "bg-violet-600 text-white"
: "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"
}`}
>
{model}
</button>
))}
</div>
</div>
</div>
{/* AI Features Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Features</h3>
</div>
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ name: "Campaign Writer", desc: "Generate email content", icon: <BotIcon className="h-4 w-4" /> },
{ name: "Product Writer", desc: "Product descriptions", icon: <SparkleIcon className="h-4 w-4" /> },
{ name: "Report Explainer", desc: "AI summaries", icon: <BotIcon className="h-4 w-4" /> },
{ name: "Pricing Advisor", desc: "Smart pricing", icon: <SparkleIcon className="h-4 w-4" /> },
].map((feature) => (
<div key={feature.name} className="bg-stone-50 rounded-lg p-3 flex items-center gap-2">
<div className="text-emerald-600">{feature.icon}</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{feature.name}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{feature.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Result Message */}
{result && (
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
result.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{result.ok ? <CheckIcon className="h-4 w-4 flex-shrink-0" /> : <AlertIcon className="h-4 w-4 flex-shrink-0" />}
{result.message}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<button type="button"
onClick={handleTest}
disabled={testing || saving}
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
{testing ? "Testing..." : "Test Connection"}
</button>
<button type="button"
onClick={handleSave}
disabled={saving || testing}
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : hasChanges ? "Save Provider Settings" : "Saved"}
</button>
</div>
</div>
);
}
@@ -1,387 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
// Icons
const MailIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>
</svg>
);
const MessageIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function AdvancedIntegrations({ brandId, brands }: Props) {
// The selected brand is always the current `brandId` prop — there is no
// in-component brand picker, so derive it directly to avoid a stale copy
// when the parent re-renders with a different brand.
const selectedBrandId = brandId;
const [credentials, setCredentials] = useState<{
resend: { api_key: string; from_email: string; from_name: string };
twilio: { account_sid: string; auth_token: string; phone_number: string };
}>({
resend: { api_key: "", from_email: "", from_name: "" },
twilio: { account_sid: "", auth_token: "", phone_number: "" },
});
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(false);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
const [saveStatus, setSaveStatus] = useState<{ type: "success" | "error"; message: string } | null>(null);
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setCredentials({
resend: {
api_key: resendCreds.api_key ?? "",
from_email: resendCreds.from_email ?? "",
from_name: resendCreds.from_name ?? "",
},
twilio: {
account_sid: twilioCreds.account_sid ?? "",
auth_token: twilioCreds.auth_token ?? "",
phone_number: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
async function handleTest(service: "resend" | "twilio") {
// Clear previous result for this service
const newResults = { ...testResults };
delete newResults[service];
setTestResults(newResults);
if (service === "resend") {
if (!credentials.resend.api_key?.trim()) {
setTestResults((prev) => ({ ...prev, resend: { ok: false, message: "API key is required" } }));
return;
}
const result = await testResendConnection(credentials.resend.api_key);
setTestResults((prev) => ({ ...prev, resend: result }));
} else {
if (!credentials.twilio.account_sid?.trim() || !credentials.twilio.auth_token?.trim()) {
setTestResults((prev) => ({ ...prev, twilio: { ok: false, message: "Account SID and Auth Token are required" } }));
return;
}
const result = await testTwilioConnection(credentials.twilio.account_sid, credentials.twilio.auth_token);
setTestResults((prev) => ({ ...prev, twilio: result }));
}
}
async function handleSave(service: "resend" | "twilio") {
setLoading(true);
setSaveStatus(null);
// Clear test result for this service
const clearedResults = { ...testResults };
delete clearedResults[service];
setTestResults(clearedResults);
try {
if (service === "resend") {
const result = await saveResendCredentials(selectedBrandId, {
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) {
setSaveStatus({ type: "success", message: "Resend credentials saved" });
setTestResults((prev) => ({ ...prev, resend: { ok: true, message: "Saved successfully" } }));
} else {
setSaveStatus({ type: "error", message: result.error ?? "Failed to save" });
}
} else {
const result = await saveTwilioCredentials(selectedBrandId, {
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) {
setSaveStatus({ type: "success", message: "Twilio credentials saved" });
setTestResults((prev) => ({ ...prev, twilio: { ok: true, message: "Saved successfully" } }));
} else {
setSaveStatus({ type: "error", message: result.error ?? "Failed to save" });
}
}
} catch (err) {
setSaveStatus({ type: "error", message: err instanceof Error ? err.message : "Failed to save" });
} finally {
setLoading(false);
}
}
return (
<div className="space-y-5">
{/* Security Warning Banner */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Never share keys</strong> Don&apos;t paste in Slack, email, or commit to GitHub.</li>
<li> <strong>SMS costs add up fast</strong> Twilio charges $0.008-$0.05 per message.</li>
<li> <strong>Set spending caps</strong> Use provider budget controls to avoid surprise bills.</li>
</ul>
</div>
</div>
</div>
{/* Resend Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500">
<MailIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Resend</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Email delivery for Harvest Reach campaigns</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* API Key */}
<div>
<label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative">
<input id="fld-1-api-key" aria-label="Re ..."
type={showSecrets.resendKey ? "text" : "password"}
value={credentials.resend.api_key}
onChange={(e) => setCredentials((p) => ({
...p,
resend: { ...p.resend, api_key: e.target.value },
}))}
placeholder="re_..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => toggleSecret("resendKey")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showSecrets.resendKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* From Email */}
<div>
<label htmlFor="fld-1-from-email" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Email</label>
<input id="fld-1-from-email" aria-label="Orders@yourbrand.com"
type="email"
value={credentials.resend.from_email}
onChange={(e) => setCredentials((p) => ({
...p,
resend: { ...p.resend, from_email: e.target.value },
}))}
placeholder="orders@yourbrand.com"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* From Name */}
<div>
<label htmlFor="fld-2-from-name" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Name</label>
<input id="fld-2-from-name" aria-label="Your Brand Name"
type="text"
value={credentials.resend.from_name}
onChange={(e) => setCredentials((p) => ({
...p,
resend: { ...p.resend, from_name: e.target.value },
}))}
placeholder="Your Brand Name"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Status & Actions */}
{testResults.resend && (
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResults.resend.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{testResults.resend.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResults.resend.message}
</div>
)}
<div className="flex gap-2">
<button type="button"
onClick={() => handleTest("resend")}
disabled={loading}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button type="button"
onClick={() => handleSave("resend")}
disabled={loading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save Resend"}
</button>
</div>
</div>
</div>
{/* Twilio Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500">
<MessageIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Twilio</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">SMS campaigns and alerts via Harvest Reach</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* Account SID */}
<div>
<label htmlFor="fld-3-account-sid" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account SID</label>
<input id="fld-3-account-sid" aria-label="AC..."
type="text"
value={credentials.twilio.account_sid}
onChange={(e) => setCredentials((p) => ({
...p,
twilio: { ...p.twilio, account_sid: e.target.value },
}))}
placeholder="AC..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Auth Token */}
<div>
<label htmlFor="fld-5-auth-token" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
<div className="relative">
<input id="fld-5-auth-token" aria-label="Your Twilio Auth Token"
type={showSecrets.twilioToken ? "text" : "password"}
value={credentials.twilio.auth_token}
onChange={(e) => setCredentials((p) => ({
...p,
twilio: { ...p.twilio, auth_token: e.target.value },
}))}
placeholder="Your Twilio auth token"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm pr-10 text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => toggleSecret("twilioToken")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showSecrets.twilioToken ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* Phone Number */}
<div>
<label htmlFor="fld-4-phone-number" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Phone Number</label>
<input id="fld-4-phone-number" aria-label="+1234567890"
type="tel"
value={credentials.twilio.phone_number}
onChange={(e) => setCredentials((p) => ({
...p,
twilio: { ...p.twilio, phone_number: e.target.value },
}))}
placeholder="+1234567890"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Status & Actions */}
{testResults.twilio && (
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResults.twilio.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{testResults.twilio.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResults.twilio.message}
</div>
)}
<div className="flex gap-2">
<button type="button"
onClick={() => handleTest("twilio")}
disabled={loading}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button type="button"
onClick={() => handleSave("twilio")}
disabled={loading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save Twilio"}
</button>
</div>
</div>
</div>
{/* Save Status */}
{saveStatus && (
<div className={`rounded-xl px-4 py-3 text-sm font-medium ${
saveStatus.type === "success"
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{saveStatus.message}
</div>
)}
</div>
);
}
-471
View File
@@ -1,471 +0,0 @@
"use client";
import { useState } from "react";
import {
createStripeConnectLink,
refreshStripeConnectLink,
disconnectStripeConnect,
createStripeDashboardLink,
getStripeConnectStatus,
} from "@/actions/stripe-connect";
interface AdvancedPaymentsProps {
brandId: string;
initialStatus: {
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
} | null;
}
// SVG Icons
const CreditCardIcon = ({ className }: { 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>
);
const ExternalLinkIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
</svg>
);
const AlertTriangleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
</svg>
);
const CheckCircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M9 12l2 2 4-4"/>
</svg>
);
const XCircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M15 9l-6 6M9 9l6 6"/>
</svg>
);
const LoaderIcon = ({ className }: { className?: string }) => (
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeOpacity="0.25"/>
<path 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>
);
const RefreshIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 019-9 9.75 9.75 0 016.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 01-9 9 9.75 9.75 0 01-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
);
const UnlinkIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18.84 12.25l1.72-1.71h-.02a5.004 5.004 0 00-.12-7.07 5.006 5.006 0 00-6.95 0l-1.72 1.71"/>
<path d="M5.17 11.75l-1.71 1.71a5.004 5.004 0 00.12 7.07 5.006 5.006 0 006.95 0l1.71-1.71"/>
<line x1="8" y1="2" x2="8" y2="5"/>
<line x1="2" y1="8" x2="5" y2="8"/>
<line x1="16" y1="19" x2="16" y2="22"/>
<line x1="19" y1="16" x2="22" y2="16"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const InfoIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
);
const StoreIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);
const DollarIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
</svg>
);
export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPaymentsProps) {
const [status, setStatus] = useState(initialStatus ?? { is_connected: false });
const [loading, setLoading] = useState(false);
const [action, setAction] = useState<"connect" | "refresh" | "dashboard" | "disconnect" | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function refreshStatus() {
const result = await getStripeConnectStatus(brandId);
if (!result.error) {
setStatus(result);
}
}
async function handleConnect() {
setLoading(true);
setAction("connect");
setError(null);
setSuccess(null);
const result = await createStripeConnectLink(brandId);
if (!result.success || !result.url) {
setError(result.error ?? "Failed to create Stripe account");
setLoading(false);
setAction(null);
return;
}
// Redirect to Stripe onboarding
window.location.href = result.url;
}
async function handleRefresh() {
setLoading(true);
setAction("refresh");
setError(null);
setSuccess(null);
const result = await refreshStripeConnectLink(brandId);
if (!result.success || !result.url) {
setError(result.error ?? "Failed to refresh onboarding");
setLoading(false);
setAction(null);
return;
}
// Redirect to Stripe to continue onboarding
window.location.href = result.url;
}
async function handleOpenDashboard() {
setLoading(true);
setAction("dashboard");
setError(null);
setSuccess(null);
const result = await createStripeDashboardLink(brandId);
if (!result.success || !result.url) {
setError(result.error ?? "Failed to open Stripe dashboard");
setLoading(false);
setAction(null);
return;
}
// Open Stripe dashboard in new tab
window.open(result.url, "_blank");
setLoading(false);
setAction(null);
}
async function handleDisconnect() {
if (!confirm("Disconnect Stripe? Your brand store will no longer be able to accept payments until reconnected.")) {
return;
}
setLoading(true);
setAction("disconnect");
setError(null);
setSuccess(null);
const result = await disconnectStripeConnect(brandId);
if (!result.success) {
setError(result.error ?? "Failed to disconnect");
} else {
setStatus({ is_connected: false });
setSuccess("Stripe disconnected successfully");
}
setLoading(false);
setAction(null);
}
const isFullyOnboarded = status.is_connected && status.charges_enabled && status.payouts_enabled && status.details_submitted;
return (
<div className="space-y-5">
{/* Info Banner - Two Stripe Systems */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500 flex-shrink-0">
<StoreIcon className="h-5 w-5 text-white" />
</div>
<div>
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)]">Brand Store Payments</h4>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This connects your brand&apos;s storefront to <strong>your own Stripe account</strong> so you can accept payments directly from your customers.
This is separate from the Route Commerce SaaS subscription billing.
</p>
</div>
</div>
</div>
{/* Security Warning Banner */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500 flex-shrink-0">
<ShieldIcon className="h-5 w-5 text-white" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">How Stripe Connect Works</h4>
<ul className="mt-2 text-xs text-amber-700 space-y-1.5">
<li> <strong>You sign in to YOUR Stripe account</strong> No API keys to manage or share.</li>
<li> <strong>Express accounts are pre-configured</strong> Card payments and transfers enabled.</li>
<li> <strong>Stripe handles compliance</strong> Identity verification and tax forms handled by Stripe.</li>
<li> <strong>Funds go directly to you</strong> Payments deposit to your connected Stripe account.</li>
<li> <strong>2-7 day payout schedule</strong> Standard Stripe payout timing to your bank.</li>
</ul>
</div>
</div>
</div>
{/* Stripe Connect Status Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
{/* Card Header */}
<div className={`px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] ${
isFullyOnboarded
? "bg-emerald-50/50"
: status.is_connected
? "bg-amber-50/50"
: "bg-stone-50/50"
}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isFullyOnboarded
? "bg-emerald-500"
: status.is_connected
? "bg-amber-500"
: "bg-stone-500"
}`}>
<CreditCardIcon className="h-6 w-6 text-white" />
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Stripe Connect</h2>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
Accept payments on your brand storefront
</p>
</div>
</div>
{/* Status Badge */}
{status.is_connected ? (
<div className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-semibold border ${
isFullyOnboarded
? "bg-emerald-100 text-emerald-700 border-emerald-200"
: "bg-amber-100 text-amber-700 border-amber-200"
}`}>
{isFullyOnboarded ? (
<CheckCircleIcon className="h-3.5 w-3.5" />
) : (
<AlertTriangleIcon className="h-3.5 w-3.5" />
)}
{isFullyOnboarded ? "Active" : "Incomplete"}
</div>
) : (
<div className="flex items-center gap-1.5 rounded-full bg-stone-100 px-3 py-1.5 text-xs font-semibold text-[var(--admin-text-secondary)] border border-stone-200">
<XCircleIcon className="h-3.5 w-3.5" />
Not Connected
</div>
)}
</div>
</div>
{/* Card Body */}
<div className="p-4 sm:p-6">
{/* Account Info */}
{status.account_id && (
<div className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)]">
<span>Account:</span>
<code className="rounded border border-[var(--admin-border)] bg-stone-50 px-1.5 py-0.5 font-mono text-[var(--admin-text-secondary)]">
{status.account_id}
</code>
</div>
)}
{/* Onboarding Status */}
{status.is_connected && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatusIndicator
label="Charges"
enabled={status.charges_enabled}
description="Can accept card payments"
/>
<StatusIndicator
label="Payouts"
enabled={status.payouts_enabled}
description="Can receive transfers"
/>
<StatusIndicator
label="Details"
enabled={status.details_submitted}
description="Business verified"
/>
</div>
)}
{/* Error/Success Messages */}
{error && (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center gap-2">
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
{error}
</div>
)}
{success && (
<div className="mt-4 rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700 flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 flex-shrink-0" />
{success}
</div>
)}
{/* Action Buttons */}
<div className="mt-5 flex flex-wrap gap-3">
{!status.is_connected ? (
<button type="button"
onClick={handleConnect}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading && action === "connect" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<CreditCardIcon className="h-4 w-4" />
)}
Connect with Stripe
</button>
) : (
<>
{!isFullyOnboarded && (
<button type="button"
onClick={handleRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-amber-500 px-5 py-2.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50"
>
{loading && action === "refresh" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<RefreshIcon className="h-4 w-4" />
)}
Complete Setup
</button>
)}
<button type="button"
onClick={handleOpenDashboard}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
{loading && action === "dashboard" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<ExternalLinkIcon className="h-4 w-4" />
)}
Stripe Dashboard
</button>
<button type="button"
onClick={handleDisconnect}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{loading && action === "disconnect" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<UnlinkIcon className="h-4 w-4" />
)}
Disconnect
</button>
</>
)}
</div>
</div>
{/* How It Works Section */}
{!status.is_connected && (
<div className="border-t border-[var(--admin-border)] px-4 py-4 sm:px-6 bg-stone-50/50">
<div className="flex items-start gap-3">
<InfoIcon className="h-4 w-4 text-[var(--admin-text-muted)] mt-0.5" />
<div>
<p className="text-xs font-medium text-[var(--admin-text-secondary)] mb-2">Setup steps:</p>
<ol className="text-xs text-[var(--admin-text-muted)] space-y-1.5 list-decimal list-inside">
<li>Click &quot;Connect with Stripe&quot; to start</li>
<li>Sign in to your Stripe account or create one</li>
<li>Complete business verification in the Stripe form</li>
<li>Once approved, start accepting payments on your store</li>
</ol>
</div>
</div>
</div>
)}
{/* Active Banner */}
{isFullyOnboarded && (
<div className="border-t border-emerald-200 px-4 py-4 sm:px-6 bg-emerald-50/50">
<div className="flex items-start gap-3">
<DollarIcon className="h-4 w-4 text-emerald-600 mt-0.5" />
<div>
<p className="text-xs font-medium text-emerald-700 mb-1">Ready to Accept Payments</p>
<p className="text-xs text-emerald-600">
Your Stripe account is fully set up. Payments from your brand storefront will be deposited directly to your Stripe account.
</p>
</div>
</div>
</div>
)}
</div>
</div>
);
}
function StatusIndicator({
label,
enabled,
description,
}: {
label: string;
enabled?: boolean;
description: string;
}) {
return (
<div className={`flex items-center gap-2.5 rounded-lg px-3 py-2 border ${
enabled
? "bg-emerald-50 border-emerald-200"
: "bg-amber-50 border-amber-200"
}`}>
{enabled ? (
<CheckCircleIcon className="h-4 w-4 text-emerald-600 flex-shrink-0" />
) : (
<AlertTriangleIcon className="h-4 w-4 text-amber-600 flex-shrink-0" />
)}
<div>
<p className={`text-xs font-semibold ${enabled ? "text-emerald-700" : "text-amber-700"}`}>
{label} {enabled ? "Enabled" : "Pending"}
</p>
<p className="text-[10px] text-[var(--admin-text-muted)]">{description}</p>
</div>
</div>
);
}
-212
View File
@@ -1,212 +0,0 @@
"use client";
import { useState } from "react";
// Icons
const TruckIcon = ({ className }: { 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>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
export default function AdvancedShipping({ brandId }: { brandId: string }) {
const [carrier, setCarrier] = useState("fedex");
const [accountNumber, setAccountNumber] = useState("");
const [apiKey, setApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTestResult(null);
if (!accountNumber.trim()) {
setTestResult({ ok: false, message: "Account number is required" });
return;
}
await new Promise(r => setTimeout(r, 1000));
setTestResult({ ok: true, message: `${carrier.toUpperCase()} connection successful` });
}
async function handleSave() {
setSaving(true);
await new Promise(r => setTimeout(r, 500));
setSaving(false);
setTestResult({ ok: true, message: "Shipping settings saved" });
}
return (
<div className="space-y-5">
{/* Security Warning */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Shipping fraud is real</strong> Stolen carrier credentials enable fake label purchases on your account.</li>
<li> <strong>Carrier API costs</strong> FedEx/UPS APIs charge per rate quote ($0.01-$0.05) and per label ($3-$10).</li>
<li> <strong>Set account limits</strong> Most carriers let you set monthly spending caps. Use them!</li>
<li> <strong>Test in sandbox</strong> Use test credentials before connecting production shipping accounts.</li>
<li> <strong>Audit regularly</strong> Check carrier invoices monthly for unauthorized activity.</li>
</ul>
</div>
</div>
</div>
{/* Shipping Carriers Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-600">
<TruckIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Shipping Carriers</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Configure shipping provider credentials</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* Carrier Selection */}
<div>
<label htmlFor="fld-1-carrier" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Carrier</label>
<select id="fld-1-carrier" aria-label="Select"
value={carrier}
onChange={(e) => setCarrier(e.target.value)}
className="w-full sm:w-auto rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="fedex">FedEx</option>
<option value="ups">UPS</option>
<option value="usps">USPS</option>
<option value="dhl">DHL</option>
</select>
</div>
{/* Account Number */}
<div>
<label htmlFor="fld-2-account-number" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account Number</label>
<input id="fld-2-account-number" aria-label="Enter Account Number"
type="text"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
placeholder="Enter account number"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* API Key / Password */}
<div>
<label htmlFor="fld-3-api-key-password" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
<div className="relative">
<input id="fld-3-api-key-password" aria-label="Enter API Key Or Password"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter API key or password"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 pr-10 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`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"
}`}>
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResult.message}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button type="button"
onClick={handleTest}
disabled={saving}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
aria-label="Test Connection">
Test Connection
</button>
<button type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
</div>
{/* Shipping Options Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Shipping Options</h3>
</div>
<div className="p-4 sm:p-6">
<div className="space-y-3">
{[
{ id: "live_rates", label: "Live Rate Quotes", desc: "Show real-time shipping rates at checkout" },
{ id: "label_printing", label: "Label Printing", desc: "Generate shipping labels automatically" },
{ id: "tracking", label: "Tracking Updates", desc: "Send tracking notifications to customers" },
{ id: "insurance", label: "Shipping Insurance", desc: "Protect high-value shipments" },
].map((option) => (
<div key={option.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
</div>
<button type="button" className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
-184
View File
@@ -1,184 +0,0 @@
"use client";
import { useState } from "react";
// Icons
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const GridIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h6v6H3V3zm0 12h6v6H3v-6zm12-12h6v6h-6V3zm0 12h6v6h-6v-6z"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
const [connected, setConnected] = useState(false);
const [appId, setAppId] = useState("");
const [accessToken, setAccessToken] = useState("");
const [locationId, setLocationId] = useState("");
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTestResult(null);
if (!appId.trim() || !accessToken.trim()) {
setTestResult({ ok: false, message: "App ID and Access Token are required" });
return;
}
// Simulate test
await new Promise(r => setTimeout(r, 1000));
setTestResult({ ok: true, message: "Square connection successful" });
setConnected(true);
}
async function handleSave() {
setSaving(true);
await new Promise(r => setTimeout(r, 500));
setSaving(false);
setTestResult({ ok: true, message: "Square sync settings saved" });
}
return (
<div className="space-y-5">
{/* Security Warning */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Square access = real money</strong> API can process transactions, refunds, and access customer data.</li>
<li> <strong>Monitor API calls</strong> Square charges per API call. Heavy integrations can run up fees.</li>
<li> <strong>Token expiry</strong> Square access tokens expire. Production apps need automatic refresh logic.</li>
<li> <strong>Use sandbox first</strong> Test with Square sandbox before going live to avoid real charges.</li>
<li> <strong>Secure storage</strong> Never expose credentials in client code or public repositories.</li>
</ul>
</div>
</div>
</div>
{/* Square Sync Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black">
<GridIcon className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Square Sync</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Sync inventory with Square POS</p>
</div>
{connected && (
<span className="ml-auto text-xs font-medium text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-full">Connected</span>
)}
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-application-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Application ID</label>
<input id="fld-1-application-id" aria-label="Sq0idp ..."
type="password"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="sq0idp-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label htmlFor="fld-2-access-token" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Access Token</label>
<input id="fld-2-access-token" aria-label="EAAAl..."
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="EAAAl..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label htmlFor="fld-3-location-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Location ID</label>
<input id="fld-3-location-id" aria-label="L..."
type="text"
value={locationId}
onChange={(e) => setLocationId(e.target.value)}
placeholder="L..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
</div>
{testResult && (
<div className={`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"
}`}>
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResult.message}
</div>
)}
<div className="flex gap-2">
<button type="button"
onClick={handleTest}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
aria-label="Test Connection">
Test Connection
</button>
<button type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
</div>
{/* Sync Options */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Sync Options</h3>
</div>
<div className="p-4 sm:p-6">
<div className="space-y-3">
{[
{ id: "inventory", label: "Inventory Sync", desc: "Sync product quantities from Square" },
{ id: "products", label: "Product Sync", desc: "Import/export products from Square catalog" },
{ id: "prices", label: "Price Updates", desc: "Keep prices synchronized" },
].map((option) => (
<div key={option.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
</div>
<button type="button" className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
-301
View File
@@ -1,301 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { updateLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
}
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
}
export type LocationForEdit = {
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;
};
type Props = {
isOpen: boolean;
onClose: () => void;
location: LocationForEdit | null;
brandId: string;
onSuccess?: () => void;
};
export default function EditLocationModal({ isOpen, onClose, location, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [stateVal, setStateVal] = useState("");
const [zip, setZip] = useState("");
const [phone, setPhone] = useState("");
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
// Reset form when location or isOpen changes — derived during render
// per React docs: "Adjusting some state when a prop changes".
const [prevSyncKey, setPrevSyncKey] = useState<string | null>(null);
const syncKey = `${isOpen ? "open" : "closed"}:${location?.id ?? "none"}`;
if (syncKey !== prevSyncKey) {
setPrevSyncKey(syncKey);
if (location && isOpen) {
setName(location.name ?? "");
setAddress(location.address ?? "");
setCity(location.city ?? "");
setStateVal(location.state ?? "");
setZip(location.zip ?? "");
setPhone(location.phone ?? "");
setContactName(location.contact_name ?? "");
setContactEmail(location.contact_email ?? "");
setNotes(location.notes ?? "");
setError(null);
}
}
const handleClose = useCallback(() => {
if (loading) return;
setError(null);
onClose();
}, [loading, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!location) return;
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await updateLocation(location.id, brandId, {
name: name.trim(),
address: address.trim() || null,
city: city.trim() || null,
state: stateVal.trim().toUpperCase() || null,
zip: zip.trim() || null,
phone: phone.trim() || null,
contact_name: contactName.trim() || null,
contact_email: contactEmail.trim() || null,
notes: notes.trim() || null,
active: location.active,
});
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error ?? "Failed to update location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
);
if (!isOpen || !location) return null;
return (
<GlassModal
title="Edit Venue"
subtitle="Changes apply to every stop currently linked to this venue."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label htmlFor="fld-1-venue-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input id="fld-1-venue-name" aria-label="Text"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-2-street-address" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input id="fld-2-street-address" aria-label="Text"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label htmlFor="fld-3-city" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input id="fld-3-city" aria-label="Text"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label htmlFor="fld-4-state" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input id="fld-4-state" aria-label="Text"
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label htmlFor="fld-5-zip" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input id="fld-5-zip" aria-label="Text"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label htmlFor="fld-6-phone" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input id="fld-6-phone" aria-label="Tel"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-7-contact-name" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input id="fld-7-contact-name" aria-label="Text"
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-8-contact-email" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input id="fld-8-contact-email" aria-label="Email"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="fld-9-notes" className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea id="fld-9-notes" aria-label="Textarea"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving
</span>
) : (
"Save Changes"
)}
</button>
</div>
</form>
</GlassModal>
);
}
-267
View File
@@ -1,267 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { getSyncLog, syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
import { getPaymentSettings } from "@/actions/payments";
type Props = {
brandId: string;
};
export default function SquareSyncWidget({ brandId }: Props) {
const [settings, setSettings] = useState<{
provider: string | null;
square_access_token: string | null;
square_sync_enabled: boolean;
square_inventory_mode: string;
square_last_sync_at: string | null;
square_last_sync_error: string | null;
} | null>(null);
const [logs, setLogs] = useState<SyncLogEntry[]>([]);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [queueCount, setQueueCount] = useState(0);
const checkQueueCount = useCallback(async () => {
const { supabase } = await import("@/lib/supabase");
const result = await supabase
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
.in("status", ["pending"]) as unknown as { count: number | null };
setQueueCount(result.count ?? 0);
}, [brandId]);
useEffect(() => {
// Initial data load — runs once on mount. Subsequent refreshes
// happen inside the event handlers (handleSyncNow) which is the
// proper React pattern: side effects live in event handlers, not
// in effects that watch props.
void (async () => {
const [settingsResult, logsResult] = await Promise.all([
getPaymentSettings(brandId),
getSyncLog(brandId),
]);
const nextSettings =
settingsResult.success && settingsResult.settings
? {
provider: settingsResult.settings.provider ?? null,
square_access_token: settingsResult.settings.square_access_token ?? null,
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
}
: null;
const nextLogs = logsResult.success ? logsResult.logs.slice(0, 5) : [];
setSettings(nextSettings);
setLogs(nextLogs);
})();
// brandId is stable for this widget's lifetime; mount-only load.
}, [brandId]);
useEffect(() => {
// Poll pending queue count every 30s
const interval = setInterval(async () => {
await checkQueueCount();
}, 30000);
(async () => {
await checkQueueCount();
})();
return () => clearInterval(interval);
}, [checkQueueCount]);
useEffect(() => {
if (!syncMsg) return;
const timer = setTimeout(() => setSyncMsg(null), 4000);
return () => clearTimeout(timer);
}, [syncMsg]);
async function handleSyncNow(type: "products" | "orders" | "all") {
setSyncing(true);
setSyncMsg(null);
const result = await syncSquareNow(brandId, type);
setSyncing(false);
setSyncMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Sync complete — ${result.synced} item(s) synced.`
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
});
const [logsResult, settingsResult] = await Promise.all([
getSyncLog(brandId),
getPaymentSettings(brandId),
]);
if (logsResult.success) {
setLogs(logsResult.logs.slice(0, 5));
}
if (settingsResult.success && settingsResult.settings) {
setSettings({
provider: settingsResult.settings.provider ?? null,
square_access_token: settingsResult.settings.square_access_token ?? null,
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
});
}
}
const lastSyncAt = settings?.square_last_sync_at
? timeAgo(settings.square_last_sync_at)
: "Never";
const lastError = settings?.square_last_sync_error;
const hasToken = !!(settings?.square_access_token);
const autoSyncEnabled = settings?.square_sync_enabled && hasToken;
const subheading = hasToken
? autoSyncEnabled
? `Wholesale products · ${lastSyncAt}`
: "Connected — auto-sync disabled"
: "Not connected";
return (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-900/40">
<svg className="h-5 w-5 text-green-400" 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>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-zinc-100">Square Inventory</h3>
{autoSyncEnabled && (
<span className="rounded-full bg-green-900/40 px-2 py-0.5 text-xs font-medium text-green-400">
Auto-sync
</span>
)}
{!autoSyncEnabled && hasToken && (
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-xs font-medium text-zinc-500">
Sync off
</span>
)}
</div>
<p className="text-sm text-zinc-500">
{subheading}
</p>
</div>
</div>
{lastError && (
<span className="rounded-full bg-red-900/40 px-2.5 py-0.5 text-xs font-medium text-red-400">
Sync Error
</span>
)}
</div>
{lastError && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-900/30 px-3 py-2 text-xs text-red-400">
{lastError}
</div>
)}
{syncMsg && (
<div className={`mb-3 rounded-lg border px-3 py-2 text-sm ${
syncMsg.kind === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}>
{syncMsg.text}
</div>
)}
{hasToken ? (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button type="button"
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Products"}
</button>
<button type="button"
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Orders"}
</button>
<button type="button"
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{syncing ? "..." : "Sync All"}
</button>
</div>
{!autoSyncEnabled && hasToken && (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-900/30 px-3 py-2 text-xs text-amber-700">
Square sync is turned off. Wholesale products will not be pushed to Square automatically.
</div>
)}
{logs.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Recent activity
</p>
{logs.slice(0, 4).map((log) => (
<div
key={log.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
log.status === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}
>
<span className="truncate">
[{log.direction ?? "—"}] {log.event_type}
</span>
<span className="ml-2 text-slate-400">
{timeAgo(log.created_at)}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex gap-2">
<Link
href="/admin/settings/square-sync"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
View Settings
</Link>
<Link
href="/admin/orders?square=1"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Square Orders
</Link>
</div>
</>
) : (
<Link
href="/admin/settings/payments"
className="inline-block rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
>
Connect Square
</Link>
)}
</div>
);
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
-179
View File
@@ -1,179 +0,0 @@
"use client";
type HeroSectionProps = {
eyebrow?: string;
title: string;
description: string;
heroImageUrl?: string | null;
heroVideoUrl?: string | null;
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick?: () => void;
onSecondaryClick?: () => void;
brandAccent?: "green" | "orange" | "blue";
};
export default function HeroSection({
eyebrow,
title,
description,
heroImageUrl,
heroVideoUrl,
primaryButton,
secondaryButton,
onPrimaryClick,
onSecondaryClick,
brandAccent = "blue",
}: HeroSectionProps) {
const hasHeroImage = !!heroImageUrl;
const hasHeroVideo = !!heroVideoUrl;
const isBlue = brandAccent === "blue";
const btnBg = isBlue ? "bg-blue-600 hover:bg-blue-500 active:bg-blue-700" : "bg-stone-900 hover:bg-stone-800 active:bg-stone-950";
if (hasHeroImage) {
return (
<section
className="relative flex min-h-[560px] items-center overflow-hidden py-28"
style={{
backgroundImage: `url(${heroImageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center top",
}}
>
<div className="absolute inset-0" style={{
background: isBlue
? "linear-gradient(135deg, rgba(0,119,190,0.25) 0%, rgba(0,119,190,0.08) 40%, transparent 70%), linear-gradient(to top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.12) 55%, transparent 100%)"
: "linear-gradient(135deg, rgba(0,0,0,0.20) 0%, transparent 45%), linear-gradient(to top, rgba(0,0,0,0.70) 0%, rgba(0,0,0,0.15) 55%, transparent 100%)"
}} />
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 relative z-10">
{eyebrow && (
<p className={`text-[10px] sm:text-xs font-bold uppercase tracking-[0.25em] mb-4 sm:mb-6 ${isBlue ? "text-blue-200" : "text-white/60"}`}>
{eyebrow}
</p>
)}
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-white leading-[1.05] max-w-3xl">
{title}
</h1>
<p className="mt-6 sm:mt-8 text-lg sm:text-xl md:text-2xl text-white/70 leading-relaxed max-w-lg font-light">
{description}
</p>
{(primaryButton || secondaryButton) && (
<div className="mt-10 sm:mt-12 flex flex-wrap gap-3 sm:gap-4">
{primaryButton && (
<button type="button"
onClick={onPrimaryClick}
className={`rounded-2xl ${btnBg} active:scale-95 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white transition-all`}
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button type="button"
onClick={onSecondaryClick}
className="rounded-2xl bg-white/10 backdrop-blur-sm border border-white/25 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white hover:bg-white/20 hover:border-white/40 active:scale-95 transition-all"
>
{secondaryButton}
</button>
)}
</div>
)}
</div>
</section>
);
}
if (hasHeroVideo) {
return (
<section className="relative flex min-h-[560px] items-center overflow-hidden py-28">
<video
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-cover"
src={heroVideoUrl}
/>
<div className="absolute inset-0" style={{
background: "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 35%, rgba(0,0,0,0.6) 72%, rgba(0,0,0,0.88) 100%)"
}} />
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 relative z-10">
{eyebrow && (
<p className="text-[10px] sm:text-xs font-bold uppercase tracking-[0.25em] mb-4 sm:mb-6 text-blue-200">
{eyebrow}
</p>
)}
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-white leading-[1.05] max-w-3xl">
{title}
</h1>
<p className="mt-6 sm:mt-8 text-lg sm:text-xl md:text-2xl text-white/70 leading-relaxed max-w-lg font-light">
{description}
</p>
{(primaryButton || secondaryButton) && (
<div className="mt-10 sm:mt-12 flex flex-wrap gap-3 sm:gap-4">
{primaryButton && (
<button type="button"
onClick={onPrimaryClick}
className="rounded-2xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 active:scale-95 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white transition-all"
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button type="button"
onClick={onSecondaryClick}
className="rounded-2xl bg-white/10 backdrop-blur-sm border border-white/25 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white hover:bg-white/20 hover:border-white/40 active:scale-95 transition-all"
>
{secondaryButton}
</button>
)}
</div>
)}
</div>
</section>
);
}
return (
<section className="py-16 sm:py-20 md:py-28 px-4 sm:px-6 bg-gradient-to-br from-stone-100 via-stone-50 to-stone-100">
<div className="mx-auto max-w-6xl">
{eyebrow && (
<p className={`text-[10px] sm:text-xs font-bold uppercase tracking-[0.25em] mb-4 sm:mb-6 ${isBlue ? "text-blue-500" : "text-emerald-600"}`}>
{eyebrow}
</p>
)}
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black tracking-tight leading-[1.05] max-w-3xl text-stone-950">
{title}
</h1>
<p className="mt-6 sm:mt-8 text-lg sm:text-xl md:text-2xl leading-relaxed max-w-lg font-light text-stone-600">
{description}
</p>
{(primaryButton || secondaryButton) && (
<div className="mt-10 sm:mt-12 flex flex-wrap gap-3 sm:gap-4">
{primaryButton && (
<button type="button"
onClick={onPrimaryClick}
className={`rounded-2xl ${btnBg} active:scale-95 px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-white transition-all`}
>
{primaryButton}
</button>
)}
{secondaryButton && (
<button type="button"
onClick={onSecondaryClick}
className="rounded-2xl bg-white px-6 sm:px-10 py-3 sm:py-4 text-sm sm:text-base font-semibold tracking-wide text-stone-950 ring-1 ring-stone-300 hover:bg-stone-50 active:scale-95 transition-all"
>
{secondaryButton}
</button>
)}
</div>
)}
</div>
</section>
);
}