fix: react-doctor unused-file 27→0 (-27 more dead files, wholesale consolidate)
This commit is contained in:
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 "Connect with Stripe" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user